1. opencode 注入用户提示词到底在解决什么问题
如果你在用 opencode 做自动化编码或者 Agent 编排,迟早会碰到一个需求:在请求真正发出去之前,动态往会话里塞一段用户提示词。比如根据当前任务状态补一句「继续按上一步的方案实现」,或者根据外部事件注入一段上下文。opencode 本身不允许插件改写已有的会话历史消息,但它留了两个钩子口子,其中一个就是用户侧的client.session.promptAsync,通过它可以把一段文本作为新的用户消息注入会话。
这个能力听起来简单,实际落地时有三个坑:注入什么内容、注入给谁(agent 和 variant 怎么读)、注入失败或超时怎么办。我试过在请求链路里直接拼字符串,结果冷启动时把用户选的思考深度降级成了默认值,排查了半天才发现是 variant 没实时读取。所以这篇不讲概念,直接给可复制的config.toml和settings.json骨架、钩子注册方式、超时参数配置,再附一次注入生效的验证动作,目标是在超时可控的前提下稳定完成提示词注入,同时把 Key 和 API 通道统一走 TaoToken。
适合谁看:需要在 opencode 请求链路里动态改写提示词、并且希望所有模型调用走统一 Key/API 通道的开发者。如果你只是想让 opencode 跑起来,这篇可能偏重了;但如果你要做 Agent 续推、任务编排、或者多会话管理,这里的钩子和超时语义就是绕不开的。
2. 接入前的准备:TaoToken 通道与 Key 获取
opencode 的模型调用最终要落到一个 API 端点上。把端点统一到 TaoToken 的好处是:一个 Key 覆盖多家模型,注入逻辑不用为每个 provider 写一套鉴权分支,超时和重试策略也能集中配置。TaoToken 的 API 地址是https://taotoken.net/api,官网入口在https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。
先拿 Key。打开控制台创建 API Key,建议按项目分 Key,方便后面排查是哪个会话在超时。创建入口在 console 页面,拿到形如sk-开头的字符串后先存到环境变量里,不要硬编码进config.toml,否则提交到仓库就泄露了。
export TAOTOKEN_API_KEY="sk-你的key"如果你用的是 Claude Code 那套 Anthropic 兼容协议,TaoToken 也提供了对应的接入文档,opencode 这边我们走标准 OpenAI 兼容的 chat 接口即可。Key 拿到后先别急着配 opencode,用一条 curl 确认通道是通的:
curl -s https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-4o-mini", "messages": [{"role": "user", "content": "ping"}], "max_tokens": 8 }'返回里有choices字段就说明 Key 和通道都没问题。这一步别跳过,后面注入超时排查时,你需要先排除「是不是 Key 本身就不通」这个变量。
3. 可复制配置:config.toml 与 settings.json 骨架
opencode 的配置分两层:config.toml管 provider 和模型端点,settings.json管插件和钩子行为。先看config.toml,核心是把 base_url 指向 TaoToken,并把超时参数显式写出来,不要依赖默认值。
# ~/.config/opencode/config.toml [provider.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api/v1" api_key_env = "TAOTOKEN_API_KEY" default_model = "gpt-4o-mini" [provider.taotoken.options] timeout_ms = 30000 max_retries = 2 [model.gpt-4o-mini] provider = "taotoken" context_window = 128000timeout_ms这里给 30000,比 SDK 钩子层的 10000 大,原因是:钩子层的超时是「发后即忘」的判定上限,而 provider 层的超时是真实网络请求的上限。两层要拉开差距,否则钩子层刚判定超时,底层请求其实还在飞,容易造成半发送。
再看settings.json,这里注册钩子并配置注入行为:
{ "plugins": { "prompt-injector": { "enabled": true, "hooks": { "chat.message": { "mode": "readonly", "record_last_agent": true, "stamp_event": true }, "session.promptAsync": { "timeout_ms": 10000, "timeout_env": "GOAL_SDK_TIMEOUT_MS", "retry_on_timeout": true } }, "injection": { "max_text_length": 512, "include_dynamic_notes": false, "read_variant_before_send": true } } } }几个参数值得单独说。chat.message的mode设成readonly,意思是用户消息进来时钩子只读不改写,它的职责限定在激活会话、记录 last_agent、刷新滑动窗口、标记活动信号这四件事。为什么不改写用户原文?一是静态前缀缓存会被动态注记击穿,二是改写会破坏 opencode 的撤销和重放语义。read_variant_before_send打开后,每次发送前实时调用session.get读当前 variant,避免冷启动把用户选的思考深度降级成默认值;读取失败时回退到内存缓存,不抛错中断注入。
4. 钩子注册与超时参数:注入链路怎么串起来
注入的调用形态是promptAsync,核心调用长这样:
await promptFn.call(session, { path: { id: session_id }, body: { ...(agent ? { agent } : {}), ...(variant ? { variant } : {}), parts: [{ type: "text", text }], }, })agent从会话当前状态读,plan 代理不参与续推,守卫放在门链层。variant发送前实时读,读不到就用缓存。parts里只放一段最小驱动文本,由continuation_prompt生成,不含动态注记。
超时这块是重点。promptAsync以SDK_CALL_TIMEOUT_MS(默认 10000,可用环境变量GOAL_SDK_TIMEOUT_MS覆盖)为上限,实现方式是手动时钟标记:定时器触发时只把timed_out置为 true,不取消底层 promise。为什么不取消?因为 SDK 超时和成功可能同时返回 undefined,with_timeout的 fallback 分不清是哪种;而且取消底层 promise 可能造成半发送,副作用不幂等。这个注入的副作用是追加一条用户消息,本身可重试,重复送达由会话自身语义消解。
调用方按返回值决策:send_prompt返回是否在时限内完成。TICK 续推忽略这个结果,下一 tick 自然重试;manage_subagent必须报告SEND_TIMEOUT,由主代理决定重试。你可以这样在环境里覆盖超时:
export GOAL_SDK_TIMEOUT_MS=15000调大这个值适合网络抖动明显的场景,调小适合需要快速失败的编排。但别把它设得比 provider 层的timeout_ms还大,否则钩子层永远等不到超时判定,重试逻辑就失效了。
5. 验证注入生效:一次可复现的请求
配置写完要验证。最直接的方式是跑一个集成测试脚本,断言注入后会话确实收到了续推文本。下面这个最小验证脚本可以直接改改用:
// verify-inject.mjs import { createClient } from "@opencode/sdk" const client = createClient({ baseUrl: "https://taotoken.net/api/v1", apiKey: process.env.TAOTOKEN_API_KEY, }) const session = await client.session.create({ agent: "build" }) const result = await client.session.promptAsync({ path: { id: session.id }, body: { parts: [{ type: "text", text: "继续按上一步方案实现" }], }, }) console.log("inject result:", result) console.log("timed_out:", result === undefined) const history = await client.session.get({ path: { id: session.id } }) const lastMsg = history.messages.at(-1) console.log("last role:", lastMsg.role) console.log("last text:", lastMsg.parts[0].text)跑之前确认GOAL_SDK_TIMEOUT_MS已设置,然后执行:
GOAL_SDK_TIMEOUT_MS=10000 node verify-inject.mjs预期输出里last role是user,last text就是你注入的那段文本。如果timed_out打印 true 但历史里又有这条消息,说明底层请求其实成功了,只是钩子层时钟先到了——这正是「发后即忘」设计要处理的场景,调用方按返回值决策即可,TICK 会自然重试。
故障注入也建议跑一遍:把promptAsync改成抛错,确认目标会话保持 active、循环不死。这一步能验证你的失败路径是否真的可重试。
6. 常见报错与排查清单
注入后会话没收到消息,但也没报错。先看agent是不是 plan 代理,plan 不参与续推,守卫在门链层直接拦掉了。再看variant读取是否抛错中断了注入,正常情况下session.get异常要捕获并回退缓存,不能往外抛。
超时误报,明明成功了却返回 undefined。这是 SDK 超时和成功同时返回 undefined 的经典情况。检查你的调用方是不是把 undefined 当成了失败。TICK 续推忽略返回值是对的,manage_subagent才需要报告SEND_TIMEOUT。
冷启动后思考深度被降级。说明read_variant_before_send没开,或者读取失败后没回退缓存。打开这个开关,并确认缓存session_variant在读取失败时被采用。
用户原文被改写导致撤销失效。检查chat.message钩子的mode是不是被改成了可写。这个钩子只该做四件事:激活会话、记录 last_agent、调用stamp_event刷新滑动窗口、调用note_user_activity标记活动信号。任何写操作都该落到持久化状态,不碰用户原文。
Key 不通导致的假超时。回到第 2 节的 curl 先确认通道。如果 curl 都超时,那问题在 Key 或网络,不在注入逻辑。这时候去 API Keys 页面重新生成一个 Key 试试,或者换模型对话页面手动发一条消息确认账号状态。
排查顺序建议:先 curl 确认通道,再确认 agent/variant 读取,最后看超时参数两层是否拉开差距。大部分「注入不生效」最后都落在 agent 守卫或 variant 读取上。
7. 把注入链路和统一通道固定下来
注入用户提示词这件事,正确定义是:发送最小驱动文本,不修改已存在消息;agent、variant、超时这些量都显式读取并给出失败路径;记录与注入分离,注入与取消分离。违背任何一条,注入会污染缓存、破坏撤销或卡死循环。
通道这边,把 base_url 固定到https://taotoken.net/api,Key 走环境变量,超时两层配置拉开差距,这套骨架就能复用到多个项目。如果你后面要做长期编码或 Agent 编排,可以考虑 Coding Plan 把配额和模型调度统一管起来;日常验证模型行为用模型对话页面就够了;接入细节和参数说明都在接入文档里。先把这篇的config.toml和settings.json跑通,再按自己的续推逻辑改continuation_prompt,比一上来就堆功能稳得多。