1. 为什么 OpenAI 工具调用在 openclaw 里需要一层适配
如果你正在用 openclaw 接 OpenAI 兼容模型,大概率遇到过这种场景:模型明明返回了工具调用,但下游 Agent 拿到的参数是半截 JSON,或者多个工具调用挤在同一个 delta 里被覆盖,最后执行时报Unexpected end of JSON input。这不是模型的问题,而是流式响应下tool_calls分片拼接没处理干净。
openclaw 的模型适配层(源码里对应src/agents/openai-transport-stream.ts)就是干这件事的:把 OpenAI 两种 API 形态——新版 Responses API 和经典 Chat Completions API——返回的工具调用,统一转成内部的toolcall_start / toolcall_delta / toolcall_end事件流。上层 Agent 只认这套事件,不关心底层是/v1/responses还是/v1/chat/completions。
这篇文章面向三类人:一是正在给 openclaw 接第三方 OpenAI 兼容通道的开发者;二是想搞清楚 function calling 流式拼接原理的后端同学;三是被delta.tool_calls增量解析坑过、想找一份可跟做配置的人。我会先讲适配层的转换逻辑,再给一份可复制的配置骨架,最后用一次完整的工具调用链路验证,并说明怎么通过 TaoToken 统一 Key 和 API 通道接入,省去多套凭证来回切。
2. TaoToken 前置:统一 Key 与 API 通道
openclaw 适配层本身只负责协议转换,它不关心你的 Key 从哪来。但实际开发里,如果你同时接 OpenAI 官方、Azure 和几个兼容接口,凭证管理会变成负担。我的做法是走 TaoToken 的统一通道,一个 Key 覆盖多种模型入口,适配层里只需要改baseURL和apiKey两个字段。
TaoToken 官网入口:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
API 基地址(不带 UTM,直接用于配置):https://taotoken.net/api
需要提前准备的东西:
- 一个可用的 API Key,在控制台创建:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite
- Key 管理页(后续轮换、限额都在这里):https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite
- 接入文档,适配层参数对照看这份:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
注意:适配层配置里
baseURL要写成https://taotoken.net/api,不要带末尾斜杠,否则部分 SDK 会拼出双斜杠路径导致 404。
如果你只是想先验证模型能不能正常返回工具调用,不想动 openclaw 代码,可以直接在模型对话页试:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite
3. 适配层配置骨架:两种 API 模式怎么选
openclaw 适配层暴露了两个工厂函数,对应两种 API 模式。选哪个取决于你的通道兼容性:
| 工厂函数 | API 端点 | 特点 | 适用场景 |
|---|---|---|---|
createOpenAIResponsesTransportStreamFn() | /v1/responses | 事件流更细,function_call_arguments.delta独立事件 | 通道支持 Responses API |
createOpenAICompletionsTransportStreamFn() | /v1/chat/completions | 兼容性最广,delta.tool_calls数组 | 第三方兼容通道、老模型 |
两者最终都走processResponsesStream或processOpenAICompletionsStream,对外暴露的事件格式一致。下面是适配层的配置骨架,我把它写成一份可直接放进 openclaw 配置目录的 JSON:
{ "transport": "openai-completions", "baseURL": "https://taotoken.net/api", "apiKey": "${TAOTOKEN_API_KEY}", "model": "gpt-4o-mini", "stream": true, "toolChoice": "auto", "tools": [ { "type": "function", "function": { "name": "exec", "description": "执行本地命令并返回输出", "parameters": { "type": "object", "properties": { "command": { "type": "string", "description": "要执行的命令" } }, "required": ["command"] } } } ] }关键字段说明:transport决定走哪个 process 函数;toolChoice设为auto让模型自己决定是否调用;tools数组会被convertTools()转成 OpenAI 的 FunctionTool 格式。如果你走 Responses API,把transport改成openai-responses,其余字段结构不变,适配层内部会调用convertResponsesTools()。
环境变量建议单独放,不要硬编码进配置文件:
export TAOTOKEN_API_KEY="sk-你的key"提示:适配层读取
apiKey时支持${VAR}占位符,这样配置文件可以进版本库,Key 留在本地环境。
4. 流式响应下 tool_calls 分片拼接与参数增量解析
这是适配层最容易出 bug 的地方。OpenAI 的流式工具调用不是一次性给你完整 JSON,而是把arguments拆成多个 delta 分片推送。以 Completions API 为例,一个exec调用的参数{"command":"dir"}可能被拆成三段:
chunk 1: delta.tool_calls[0] = { index: 0, id: "call_abc", function: { name: "exec", arguments: "{\"comm" } } chunk 2: delta.tool_calls[0] = { index: 0, function: { arguments: "and\":\"d" } } chunk 3: delta.tool_calls[0] = { index: 0, function: { arguments: "ir\"}" } }适配层的处理逻辑是维护一个currentBlock,每来一个 delta 就做三件事:拼接partialArgs、用parseStreamingJson()尝试增量解析、推送toolcall_delta事件。核心代码结构如下:
if (choice.delta.tool_calls && choice.delta.tool_calls.length > 0) { for (const toolCall of choice.delta.tool_calls) { // 新工具调用开始:id 出现即视为新块 if (!currentBlock || currentBlock.type !== "toolCall" || toolCall.id) { finishCurrentBlock(); currentBlock = { type: "toolCall", id: toolCall.id || "", name: toolCall.function?.name || "", arguments: {}, partialArgs: "", }; output.content.push(currentBlock); stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output }); } // 参数增量累积 if (toolCall.function?.arguments) { currentBlock.partialArgs += toolCall.function.arguments; currentBlock.arguments = parseStreamingJson(currentBlock.partialArgs); stream.push({ type: "toolcall_delta", contentIndex: blockIndex(), delta: toolCall.function.arguments, partial: output, }); } } }parseStreamingJson()是容错解析的关键:当partialArgs还是{"comm这种半截 JSON 时,它不会抛异常,而是返回一个部分对象或空对象,等后续分片补齐后再解析出完整结构。这样上层即使在中途读取arguments也不会崩。
Responses API 的处理更细,参数增量走独立的response.function_call_arguments.delta事件,适配层在response.output_item.added时创建currentBlock,在response.output_item.done时用parseStreamingJson(currentBlock.partialJson)做最终解析并推送toolcall_end。两种 API 的差异被这层完全吸收。
5. 验证请求:一次完整工具调用链路
配置好之后,用一段最小请求验证适配层是否正常工作。下面这个 Node 脚本直接打 TaoToken 的 Completions 端点,模拟 openclaw 适配层的请求构建方式:
const res = await fetch("https://taotoken.net/api/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${process.env.TAOTOKEN_API_KEY}`, }, body: JSON.stringify({ model: "gpt-4o-mini", stream: true, tool_choice: "auto", tools: [{ type: "function", function: { name: "exec", description: "执行本地命令", parameters: { type: "object", properties: { command: { type: "string" } }, required: ["command"], }, }, }], messages: [{ role: "user", content: "帮我列出当前目录文件" }], }), }); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop(); for (const line of lines) { if (!line.startsWith("data: ")) continue; const payload = line.slice(6); if (payload === "[DONE]") continue; const chunk = JSON.parse(payload); const tc = chunk.choices?.[0]?.delta?.tool_calls; if (tc) console.log("tool_call delta:", JSON.stringify(tc)); } }预期你会看到类似这样的分片输出,arguments被拆成多段:
tool_call delta: [{"index":0,"id":"call_x1","function":{"name":"exec","arguments":""}}] tool_call delta: [{"index":0,"function":{"arguments":"{\"comm"}}] tool_call delta: [{"index":0,"function":{"arguments":"and\":\"ls\"}"}}]适配层把这些分片拼完后,会推送一个toolcall_end事件,携带完整结构:
{ "type": "toolcall_end", "toolCall": { "type": "toolCall", "id": "call_x1", "name": "exec", "arguments": { "command": "ls" } } }拿到这个事件后,pi-embedded-subscribe.handlers.tools.ts会触发实际执行,执行结果通过emitToolResultOutput()转成function_call_output(Responses API)或tool消息(Completions API),作为下一轮请求的输入回填给模型。整条链路就闭合了。
6. 本篇常见错排查
报错一:Unexpected end of JSON input
原因通常是parseStreamingJson()没做容错,直接对半截 JSON 调了JSON.parse。检查你的适配层是否在toolcall_delta阶段就尝试解析完整对象。正确做法是增量阶段只累积字符串,toolcall_end时才做最终解析。
报错二:多个工具调用互相覆盖
当模型一轮返回两个tool_calls时,delta.tool_calls数组里会有index: 0和index: 1。如果适配层只维护一个currentBlock,第二个会覆盖第一个。修复方式是按index建 map,或者像 openclaw 那样在检测到新id时先finishCurrentBlock()。
报错三:toolcall_start事件重复推送
Completions API 的第一个 delta 里id和name同时出现,后续 delta 只有arguments。如果你的判断条件是toolCall.id存在就新建块,那第一个 delta 之后每个带id的都会误判。openclaw 的判断是!currentBlock || currentBlock.type !== "toolCall" || toolCall.id,注意toolCall.id只在真正新调用时才有值。
报错四:请求 404 或路径拼接错误
baseURL末尾带了斜杠,SDK 又拼了/v1/chat/completions,结果变成//v1/...。统一写成https://taotoken.net/api,不要带尾斜杠。
报错五:工具结果回填后模型不继续
检查toolResult的格式是否匹配当前 API 模式。Responses API 要转成function_call_output,Completions API 要转成role: "tool"的消息,且必须带tool_call_id。格式不对模型会当成普通文本忽略。
7. 接入与后续:按场景选通道
排障和接入阶段,重点是把 Key 和文档对齐,建议直接看 API Keys 管理页和接入文档,参数对照着改适配层配置:
- API Keys:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite
- 接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
如果你只是想快速验证某个模型返回的工具调用结构对不对,不用写代码,在模型对话页发一句带工具意图的 prompt 就能看到原始响应:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite
长期跑编码类 Agent、需要稳定长连接和额度管理的,走 Coding Plan 更合适,省得每次手动换 Key:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite
最后说个我踩过的坑:适配层的parseStreamingJson()一定要用 try-catch 包住,并且对空字符串返回{}而不是null。因为toolcall_delta阶段上层可能随时读取arguments做 UI 渲染,返回null会让渲染层直接报错。这个细节在源码里体现为stringifyJsonLike(item.arguments, "{}")的默认值处理,抄配置的时候别漏了。