跳到主要内容

工具调用循环与 provider 双轨

本章讲什么: agent 的心脏是一个递归循环——「问模型 → 模型要调工具就执行 → 把工具结果拼回消息 → 再问模型 → ……直到模型给纯文本」。本章先讲这个循环(handleExecution),再讲它最巧妙的地方:同一套循环既驱动「会原生函数调用」的强模型,也驱动「只能靠 prompt 假装会工具调用」的弱模型——靠的是 provider 层的双轨设计。


3.1 工具调用循环

它要解决的小问题

模型说「我要调 web-browsing 查 X」——但模型自己不能真的联网。需要有人:接住这个工具调用请求、真去执行、把结果塞回对话、然后再问一次模型(也许它看了结果还要调下一个工具,也许它就能回答了)。这个「再问一次」可能发生很多轮,所以是个递归循环

直觉:递归 + 深度计数

handleExecution(messages, functions, depth=0)

├─ provider.complete(messages, functions) 问模型

├─ 模型回了 functionCall ?
│ │ 是
│ ├─ 查到工具 fn → result = await fn.handler(args) 真执行
│ ├─ 把 {role:"function", content:result} 拼进 messages
│ └─ return handleExecution(newMessages, functions, depth+1) ← 递归!

└─ 模型回了纯文本 ? → return textResponse 循环出口

对应 handleExecution(server/utils/agents/aibitat/index.js:1135)。每递归一层 depth+1,撞到 maxToolCalls(默认 10,index.js:73 defaultMaxToolCalls)就强制收尾:再执行一次当前工具,然后把后续可用工具清空(传 []),逼模型只能出文本(index.js:1159:1248)。

真实实现(简化骨架)

// 这段是 handleExecution 的主干,演示「执行工具→拼结果→递归」;重点看末尾的递归调用
const completion = await this.providerInstance.complete(messages, functions);
if (completion.functionCall) {
const { name, arguments: args } = completion.functionCall;
const fn = this.functions.get(name);
if (!fn) { /* 工具不存在:拼一条「Function not found」再递归,让模型改 */ }

const result = await fn.handler(args); // 真正执行工具
Telemetry.sendTelemetry("agent_tool_call", { tool: name });
this.emitter.emit("toolCallResult", { toolName: name, arguments: args, result });

const newMessages = [...messages,
{ name, role: "function", content: result,
originalFunctionCall: completion.functionCall }];
return this.handleExecution(newMessages, functions, depth + 1, msgUUID); // 递归
}
return completion?.textResponse; // 出口:纯文本

(真实代码 index.js:1152-1264)。

两个工程巧思

skipHandleExecution「直接输出」短路。 有些工具(比如 Flow 执行)希望结果直接当成最终回答,不要再喂给模型多绕一圈。内核留了个 skipHandleExecution 标志(index.js:29):工具执行后若该标志被置 true,循环立刻把 result 当文本返回、不再调模型、不再调工具(index.js:1208)。这既省一次 LLM 调用,也防止工具结果被模型「重写跑偏」。

② 工具吐图片 → 回灌成 user 消息。 工具可以调 addToolAttachment 把图片排队(index.js:275)。循环在拼下一轮消息时,把这些图片作为一条 role:"user" 的多模态消息插进去(index.js:1093),这样复用了 provider 现成的「用户消息带图」处理,让模型「看见」工具产出的图。


3.2 流式版本:handleAsyncExecution

handleAsyncExecution(index.js:982)是上面循环的流式孪生。逻辑一模一样,差别在:

  • 调的是 providerInstance.stream(...) 而非 complete(...),边生成边把 token 推给前端;
  • 工具调用、引用、用量(usage)都通过 eventHandler → socket 实时上报。

选哪条路由在 reply 里决定:provider.supportsAgentStreaming 为 true 就走 async(index.js:933)。OpenAI 等现代 provider 返回 true(openai.jsget supportsAgentStreaming),老/弱 provider 默认 false(基类 ai-provider.jsget supportsAgentStreaming 返回 false)。两条路并存是上游正在把所有 provider 迁移到流式的过渡态(基类注释明说)。


3.3 provider 双轨:native vs UnTooled

这是整个 agent 子系统最值得借鉴的设计。

问题:不是所有模型都会「函数调用」

OpenAI/Anthropic 有原生 tool-calling API,模型会结构化地吐出 {name, arguments}。但很多本地/开源模型(经 Ollama、LMStudio、KoboldCPP 等跑)没有这个能力——它们只会生成文本。怎么让同一套 agent 循环也能驱动它们?

答案:两轨,对内核暴露同一接口

内核 handleExecution 只认识 provider 的两个方法:complete(messages, functions)stream(...),期待返回 {functionCall, textResponse}至于这个 functionCall 是模型原生吐的还是「假装」出来的,内核不在乎。

原生轨(native)模拟轨(UnTooled)
代表 providerOpenAI、Anthropic、Gemini…Ollama、LMStudio、KoboldCPP…
工具怎么传作为 API 的 tools/functions 字段塞进 system prompt 当「说明书 + 示例」
模型怎么吐工具调用结构化 function_call吐一段 JSON 文本,代码再解析
基类/混入直接用 Provider继承/混入 UnTooled

native 轨长什么样

OpenAIProvider(server/utils/agents/aibitat/providers/openai.js)用 OpenAI Responses SDK:把工具格式化成 tools 字段(#formatFunctions,openai.js),从流式事件里抠出 function_call,返回结构化的 functionCall。它 supportsNativeToolCalling() 恒为 true、supportsAgentStreaming 为 true。

UnTooled 轨长什么样(精华)

UnTooled(server/utils/agents/aibitat/providers/helpers/untooled.js)给「不会函数调用」的模型造了一个假的工具调用层。核心三步:

① 把工具变成 prompt 里的「说明书」。 showcaseFunctions(untooled.js:30)把每个工具的名字、描述、参数 schema、few-shot 示例渲染成文本;buildToolCallMessages(untooled.js:127)把它们包进一段 system 提示,命令模型「只输出 JSON,带 name 和 arguments 两个键,不要别的」:

You are a program which picks the most optimal function and parameters to call.
...
All JSON responses should have two keys.
'name': ... 'arguments': ...
${showcaseFunctions(functions)}

② 解析模型吐的 JSON 当工具调用。 functionCall(untooled.js:155)把模型输出 safeJsonParse;解析失败 → 当成普通文本回答;解析成功 → 进入校验。

③ 严格校验,挡住幻觉。 validFuncCall(untooled.js:81)检查:工具名存在吗?必填参数齐吗?有没有 schema 之外的多余参数(模型常幻觉乱加)?任一不过就丢弃这次调用。

// 这段演示 UnTooled 的「假工具调用」:渲染说明书 → 让模型出 JSON → 解析+校验
const historyMessages = this.buildToolCallMessages(history, functions); // 工具进 prompt
const response = await chatCb({ messages: historyMessages }); // 模型出文本
const call = safeJsonParse(response, null);
if (call === null) return { toolCall: null, text: response }; // 不是 JSON → 文本
const { valid, reason } = this.validFuncCall(call, functions); // 校验
if (!valid) return { toolCall: null, text: null }; // 非法 → 丢弃
return { toolCall: call, text: null };

这样,弱模型「假装」会函数调用,而 handleExecution 完全感知不到差别——适配的复杂度被锁在 provider 层,内核保持干净

防循环:Deduplicator

弱模型有个老毛病:把同一个工具用同样参数调 N 遍(「保存文件…保存文件…保存文件…」)。UnTooled 内置 Deduplicator(untooled.js:7utils/dedupe.js):每次工具调用按 sha256({name,args}) 记指纹,isDuplicate 命中完全相同的历史调用就拒绝(dedupe.js:60)。这块在第 04 章细讲。


4. 巧妙之处

  • 递归 + 深度上限 = 简洁又安全的工具循环。 用函数递归而非显式状态机表达多轮工具调用,depth>=maxToolCalls 时清空可用工具逼出文本(index.js:1159),天然防无限循环。
  • functionCall 抽象隔离强弱模型。 内核只认 {functionCall, textResponse},把「原生 vs prompt 模拟」的全部差异封死在 provider 层——新增一个弱模型 provider 只需混入 UnTooled
  • 直接输出短路(skipHandleExecution)。 让「结果即答案」的工具跳过多余的 LLM 往返(index.js:1057:1208)。

5. 边界与局限

  • UnTooled 的工具调用靠模型「听话输出 JSON」,模型越弱越容易解析失败或幻觉参数;失败时只能降级成普通文本回答(untooled.js:159)。
  • 去重器对「合法的重复调用」会误伤:如「创建 3 张 Jira 工单」会被当重复挡掉,得设 MCP_NO_COOLDOWN 关掉冷却(untooled.jsisMCPTool 注释明说)。
  • parallel_tool_calls: false(openai.js)——一次只允许一个工具调用,没有并行工具。

6. 代码地图

主题文件符号
同步工具循环server/utils/agents/aibitat/index.jshandleExecution
流式工具循环server/utils/agents/aibitat/index.jshandleAsyncExecution
工具次数上限server/utils/agents/aibitat/index.jsdefaultMaxToolCalls, maxToolCalls
直接输出短路server/utils/agents/aibitat/index.jsskipHandleExecution
provider 基类server/utils/agents/aibitat/providers/ai-provider.jsProvider, supportsAgentStreaming, stream
原生轨server/utils/agents/aibitat/providers/openai.jsOpenAIProvider, #formatFunctions, stream, complete
模拟轨server/utils/agents/aibitat/providers/helpers/untooled.jsUnTooled, showcaseFunctions, functionCall, validFuncCall
去重server/utils/agents/aibitat/utils/dedupe.jsDeduplicator