OpenRouter Agent SDK 的 call-model 层:与传统 chat completions 的分工
OpenRouter 的文档里有两套写法能做同一件事:一套是大家熟的 chat/completions 请求体,工具用 tools 字段传进去,模型回 tool_calls,你在本地跑完再把结果拼回 messages 发第二次;另一套是 Agent SDK 里的 callModel。两者不是新旧替代关系,而是把”多轮循环”这段责任交给谁的差别。
裸调时,那个循环是你自己的
官方文档《Tool & Function Calling》页把裸调的工具调用拆成三步:带 tools 发一次推理请求;模型返回 tool_calls 后由你在本地执行;再把 role: "assistant" 带 tool_calls 的那条消息和 role: "tool"、tool_call_id 的结果消息一起追加进 messages 发第二次。这一页专门标了一句:tools 参数第一步和第三步都要带上,这样 router 每次都能校验工具 schema。示例代码里还留了句提醒——把模型返回的那条消息 push 回 messages “很容易忘”。
三步只覆盖”模型调一次工具就收工”。真实场景要循环,同一页给了一段《A Simple Agentic Loop》:
const maxIterations = 10;
let iterationCount = 0;
while (iterationCount < maxIterations) {
iterationCount++;
const response = await callLLM(messages);
if (response.choices[0].message.toolCalls) {
messages.push(await getToolResponse(response));
} else {
break;
}
}
if (iterationCount >= maxIterations) {
console.warn("Warning: Maximum iterations reached");
}
这是官方文档里的原样示例,里面的迭代上限只是示例值,不是平台约束。它把裸调形态的职责摆得很清楚:什么时候停、消息历史谁来拼、工具名怎么映射到本地函数、参数 JSON 谁来解析、超出上限之后怎么办,一行不落全在你这边。好处也在这里——每一处都是你自己的代码,插日志、插审批、插降级都是随手的事。
callModel 接管了循环的哪几段
Agent SDK 的《Tools》页写得很直接:callModel 会自动执行工具并处理多轮对话,模型调工具时 SDK 执行它、把结果送回模型、继续下去直到模型给出最终回答。这一页列的执行序列是六步:模型生成工具调用 → SDK 取出调用并校验参数 → 跑 execute → 结果格式化后回传模型 → 模型给最终回答或继续调工具 → 重复直到模型结束。
对照上面那段 while 循环,差异集中在三处。
第一,停止条件从常量变成了对象。《Stop Conditions》页写明:不传 stopWhen 时,循环会一直跑到模型产出”没有工具调用”的那一轮为止;文档同时明确建议总是显式传 stopWhen(如 stepCountIs(n)、maxCost(...))来给迭代、成本或 token 划界。内置条件有 stepCountIs(n)、hasToolCall(name)、maxTokensUsed(n)、maxCost(amount)、finishReasonIs(reason),可组合。《Tools》页里还留着 maxToolRounds(可传数字,也可传一个接收 TurnContext、返回 true 继续 / false 停止的函数),而《Stop Conditions》页给的是从 maxToolRounds 迁移到 stopWhen 的写法——照旧例子写的话,翻到停止条件那页会看到它推荐的是另一个入口。
第二,“停下来之后”多了一轮。 这是裸调时最容易漏掉、也最能说明分工的一处。文档写明:停止条件可能在模型还在发工具调用的时候触发,默认情况下 SDK 会先把这些待执行的工具调用跑完(好让它们有配对的输出),然后再发一次模型请求,这次带 toolChoice: 'none',并把一段内置的最终回答指令(导出为 DEFAULT_FINAL_RESPONSE_DIRECTIVE)作为用户消息追加进去。文档自述这么做的理由是:有些模型会把工具调用语法当文本吐出来,循环卡在半截时会把没解析的 <tool_call>… 泄漏进最终内容里,这条默认指令就是防这个的。同一段还写明,这一轮里工具仍然留在请求体中,只是禁止调用,为的是保住 prompt-cache 的前缀。
这个行为用 allowFinalResponse 调:默认(省略或 true)补一轮加内置指令;传字符串替换指令措辞;传 '' 补这一轮但不追加任何消息;传 false 关掉这一轮,直接停在被拦下的那次工具调用上。触发面文档也限定了:只有”停止条件是在最后一次响应仍含可执行工具调用时中断了循环”才有这一轮,自然结束、HITL/审批暂停、被中断都不会触发。
配套还有一条容错:文档说有些 mini 级模型会偶发地把一次成功的工具调用当成终结答案,最终轮返回空的 output 数组。默认行为是重试一次这次跟进请求,还空就以空文本收场,而不是抛 Invalid final response: empty or invalid output。这层容忍只在至少完成过一轮工具执行之后才生效。想要严格契约就传 strictFinalResponse: true。
第三,能退回去。 maxToolRounds: 0 会关掉自动执行,你直接拿到原始的工具调用;配合 execute: false 定义的手动工具(文档把这类工具描述为”需要你自己处理工具调用”),就能用 getToolCalls() 取出调用后自行决定是否放行。也就是说这不是二选一的架构决定——你可以只借它的类型与解析,不借它的循环。
流式消费:chunk 累加 vs 按 ID 替换
《Working with Items》页把这条差异摆在最前面:callModel 建在 OpenRouter 的 Responses API 上,用的是 items 模型而不是 OpenAI Chat / Vercel AI SDK 那种 messages 模型。同一个 item 会以同一个 ID 多次发出,内容逐步更新,你按 ID 整个替换而不是累加 chunk。 对照表里列的四条差异是:累加 vs 按 ID 替换、单一消息类型 vs 多种 item 类型、结尾重建内容 vs 每次发出都是完整的、手动状态管理 vs 天然适配 React state。
getItemsStream() 会产出的 item 类型,文档列了七种:message、function_call、reasoning、web_search_call、file_search_call、image_generation_call、function_call_output。裸调时这些东西都得你自己分流并累加。
这里有一处必须照实标出来:getNewMessagesStream() 已被文档标为 deprecated,建议迁移到 getItemsStream(),理由是前者只暴露 message,后者包含全部 item 类型。照旧例子写的代码要留意。
循环上的观测与控制点
裸调时想在工具执行前后加审计,就在自己那段 while 里插两行;callModel 接管循环之后,这些位置由《Lifecycle Hooks》页定义。这一页列出的内置 hook 共九个,右列是文档标注的作用:
| Hook | 触发时机 | 作用 |
|---|---|---|
SessionStart | 初次请求前 | 观察 |
UserPromptSubmit | 初始提示词发出前 | 改写或拒绝 |
PostModelCall | 每次模型响应后 | 观察 |
PermissionRequest | 工具审批前 | allow / deny / ask_user |
PreToolUse | 客户端工具执行前 | 改写或阻断 |
PostToolUse | 工具成功后 | 观察 |
PostToolUseFailure | 工具失败后 | 观察 |
Stop | stopWhen 停住循环时 | 追加提示词或恢复 |
SessionEnd | 运行退出时 | 观察 |
文档开头就点了一句容易搞混的分界:lifecycle hooks 和 SDKHooks、BeforeRequestHook 这类 transport hooks 不是一回事——后者拦的是 HTTP 请求,前者只在 agent 循环内的事件上触发。要改 header 别往 lifecycle hooks 里找。
几处边界,官方文档写明、但按裸调的直觉容易猜错:
- 模型给了非法 JSON 参数时,工具类 hook 根本不触发。 文档写明这种情况 SDK 直接产生 parse error,因为”不存在有效的工具输入”——你在
PreToolUse里做的日志会缺这一类。 PostToolUseFailure只管”跑了并出错”。 工具压根没跑的情形——PermissionRequest拒绝、用户拒绝、PreToolUse阻断、非法 JSON 参数、HITL 挂起等待输入——都不触发它,得去对应的 gating hook 里看。HITL 恢复后成功完成才会触发。Stop里的forceResume有硬上限。 文档写明连续三次无进展的覆盖之后就不再放行;工具产出或新的模型响应会重置计数,而被阻断、被拒绝的工具输出也算进展(文档给的理由是模型收到了反馈)。单独用forceResume通常会立刻再撞上同一个停止条件,文档建议配appendPrompt。PostModelCall在无工具的流式路径上触发得很晚。 文档写明这条路径下响应要等流被消费完才算完整,于是这个 hook 在会话拆解阶段才触发,紧挨着SessionEnd。流失败到从未产出完整响应时不触发它;response.incomplete但已物化的响应会触发。- 审批与 HITL 的恢复调用会跳过
SessionStart和SessionEnd,但工具类 hook 照常触发。还有一条措辞值得记:文档写”审批暂停目前(currently)以reason: 'complete'结束”——SessionEnd的reason取值是user | error | max_turns | complete,拿它区分”真做完了”和”停在审批上”会看走眼。
handler 链的规则也写明了:按注册顺序执行,mutation 会替换掉后续 handler 看到的对应字段,block 或 reject 会中断剩余链条(阻断者自己的 mutation 仍先应用);空字符串既不阻断也不拒绝。默认情况下 handler、matcher、filter 抛错会被记录并跳过,想让错误冒出来就 new HooksManager(undefined, { throwOnHandlerError: true })。异步侧,返回 { async: true, work, asyncTimeout } 这种 AsyncOutput 能把遥测甩到后台不拖慢循环,默认超时文档写明是 30 秒(这是文档写明的默认值,随版本可能变动)。
下一轮参数:裸调天生就有,SDK 把它收进了工具里
裸调时”下一轮请求带什么”从来不是问题——请求体是你自己拼的,想换模型、想追加 system 指令,改就是了。callModel 接管循环之后这个入口就需要显式化,这就是 nextTurnParams。
《Next Turn Params》页写明它的执行位置:模型生成工具调用 → 所有工具的 execute 跑完 → 每个工具的 nextTurnParams 按 tools 数组顺序执行 → 修改后的参数用于下一轮模型调用 → 重复到模型不再调工具。它拿到两个参数:params 是按 inputSchema 校验过的工具输入,context 是当前请求上下文,文档列的属性有 input、model、models、instructions、temperature、maxOutputTokens、topP、topK。可改的就是 CallModelInput 里那些参数。
文档的最佳实践里有一条值得抄进自己的代码规范:幂等检查。因为循环会反复过这段逻辑,示例的做法是先 JSON.stringify(context.input).includes(marker),命中就原样返回 context.input,不重复注入。这是裸调时靠”我知道我只拼了一次”糊过去的问题——循环交出去之后,就得写成显式检查。
nextTurnParams: {
input: (params, context) => {
const marker = `[Context: ${params.id}]`;
// Don't add if already present
if (JSON.stringify(context.input).includes(marker)) {
return context.input;
}
return [...context.input, {
role: 'user',
content: `${marker}\n${newContent}`,
}];
},
},
从处境倒推:什么时候选哪个
- 一次问答、不带工具:裸调
chat/completions已经够了;用callModel也不吃亏,getText()就是一行。这一维没什么值得纠结的。 - 要多轮工具、循环逻辑想自己控:继续裸调,或者用
callModel把maxToolRounds设成0拿原始工具调用,后者的价值是保留了参数校验与类型推断。 - 要在工具执行前后做统一的阻断、改写、审计:这是 lifecycle hooks 的主场。裸调也能做,但每个分支都得你自己保证插到了——尤其”工具没跑”的那几种分支,SDK 把它们分给了不同的 hook,边界写死在文档里。
- 要人在环审批:
PermissionRequest的三态与 HITL 恢复语义是现成的,注意上面那条SessionEnd的reason陷阱。 - 前端要按 item 渲染:items 的”按 ID 整体替换”确实少一层累加代码。
- 你的服务不是 TypeScript:《Call Model》这一页的标题就是
Call Model (Typescript),包名@openrouter/agent。其他语言是否有等价的callModel循环层,我们在这几页文档里没有找到对应说明,不比,请自行核对官方各语言 SDK 文档。
收口:裸调把循环的全部自由和全部责任都给你,callModel 把循环收走,并在收走的位置上定义了停止、补最终一轮、观测、下一轮参数写入这几个契约——差异会咬到你的地方几乎都在”循环结束的那一刻”:停在半截的工具调用怎么收尾、空的最终响应算不算失败、审批暂停的 reason 是什么。这几处值得在选型前翻一遍原文。
以上代码片段均取自官方文档、未做改写;组合使用时,以上为按官方文档中的字段语义组合的示例,未经实测,以官方文档与 API 的实际响应为准。该平台与 SDK 迭代频繁,文中字段、默认值与行为随版本变动,请以官方文档最新内容为准。
最后补一句与文档无关的通用做法:官方文档的示例里客户端是用 apiKey: process.env.OPENROUTER_API_KEY 初始化的,跑之前要先把这个环境变量设好,Linux/macOS 是 export OPENROUTER_API_KEY="<YOUR_API_KEY>",Windows PowerShell 是 $env:OPENROUTER_API_KEY="<YOUR_API_KEY>"(仅当前会话有效)。这属于各操作系统的通用做法,不是 OpenRouter 官方文档的内容;密钥不要写进代码仓库。
本文依据 OpenRouter 官方文档(openrouter.ai/docs)于 2026-08-18 的公开内容整理。
该平台闭源,本文只复述官方文档写明的机制,不推断其内部实现;
我们没有对文中涉及的功能做过实测,因此不涉及界面外观与运行表现的任何描述。
该平台的供应商、模型与路由策略随时变动,文中不列具体供应商名单与模型清单;
价格、额度与限流的具体数值请以官方定价页与用量说明为准。
本文对照的是同一产品内的两种形态,依据均为上述官方文档,不对两种形态做优劣排名, 选型结论只在官方文档写明的能力边界内成立。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。