1. 从一次工具调用说起Agent 循环到底卡在哪ClawdBotMoltBot这类 AI 智能体最让人上头的地方是它能自己开终端、读文件、发消息像人一样在本地电脑上连续操作好几步。但真到自己动手复刻时很多人会卡在同一个地方模型明明返回了 tool_calls本地也执行了可下一轮请求发出去模型却像失忆一样要么重复调用同一个工具要么干脆不认之前的结果。问题不在模型而在 Agent 循环没有闭合。所谓 Agent 循环本质就是一条消息进来后本地跑一轮「LLM 决策 → 工具调用 → 本地执行 → 结果回传 → 再调 LLM」可以重复多轮直到模型不再发起工具调用或命中停止条件。ClawdBot 的入口在runEmbeddedPiAgent会话由createAgentSession()创建或恢复用户输入通过activeSession.steer(prompt)驱动。模型返回的 assistant 消息里带tool_callsSession 根据工具名找到对应的execute(...)在本地跑拿到AgentToolResult后转成 tool result 消息追加进对话历史再请求 LLM。这条链路任何一环断了循环就闭不上。这篇聚焦 Function Calling 在多轮循环里的衔接方式给出可复制的settings.json/config.toml骨架和统一 Key 配置片段并附上本地验证循环是否闭合的具体动作。适合正在写微信客服机器人、桌面自动化 Agent或者任何需要模型连续调用本地工具的场景。如果你只是想让模型聊聊天这篇可能偏重但只要涉及「模型自己决定调哪个工具、调完还要接着想」下面的结构就能直接复用。2. 前置准备用 TaoToken 统一 Key 收口多厂商调用ClawdBot 的工具调用层遵循的是 OpenAI / Anthropic / Google 等厂商通用的 Function CallingTool Use协议由pi-ai做厂商适配。这意味着你的 Agent 可能同时要面对好几套 API 格式OpenAI 的tools字段、Anthropic 的tool_useblock、Google 的functionDeclarations。每接一个厂商就改一次请求构造代码循环还没跑通适配层先写吐了。我的做法是把模型调用统一收口到 TaoToken。它提供 OpenAI 兼容的接口一个 Key 就能覆盖多家模型Agent 侧只需要维护一套请求格式pi-ai那层适配逻辑可以大幅简化。官网在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 注意 API 地址不带 UTM 参数。先拿到 Key进控制台 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 在 API Keys 页面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 创建一个。建议按项目建独立 Key方便后面排查是哪个 Agent 在刷调用。Key 拿到后不要硬编码进源码走环境变量或本地配置文件。注意统一 Key 的价值不只是省事。当 Agent 循环出问题时你能在一个地方看到所有厂商的请求日志快速判断是模型没返回 tool_calls还是本地 execute 抛了异常还是结果回传格式不对。多 Key 分散时这类排查会变成体力活。如果你打算长期跑编码类 Agent可以了解下 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 只是想先验证模型能不能正确发起工具调用用模型对话 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite 手动试一轮最快。接入细节看文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。3. 可复制配置settings.json 与 config.toml 骨架下面这套配置把「模型接入」和「Agent 循环」分开管理。settings.json管模型与 Keyconfig.toml管工具注册与循环参数。两者都做了脱敏把占位符换成你自己的值即可。3.1 settings.json模型与统一 Key{ provider: { baseUrl: https://taotoken.net/api, apiKeyEnv: TAOTOKEN_API_KEY, defaultModel: claude-sonnet-4-20250514, fallbackModel: gpt-4o-mini, timeoutMs: 60000, maxRetries: 2 }, agent: { maxLoopTurns: 12, stopOnNoToolCall: true, toolResultMaxChars: 8000, appendToolResultAsRole: tool }, session: { transcriptDir: ~/.clawdbot/agents/default/sessions, compaction: { enabled: true, reserveTokensFloor: 8000, triggerRatio: 0.85 } } }几个参数值得展开。maxLoopTurns是循环上限防止模型陷入「调工具 → 结果不满意 → 再调同一个工具」的死循环12 轮对大多数桌面操作够用。stopOnNoToolCall设为 true 时模型回复里没有tool_calls就结束本轮这是循环闭合的天然出口。toolResultMaxChars控制单个工具结果回传的长度终端命令输出动辄几万字符不截断会直接把 context 撑爆。appendToolResultAsRole指定结果以什么角色追加OpenAI 系用toolAnthropic 系在pi-ai适配后也统一成这个语义。compaction那段对应短期记忆管理。当历史接近 context 上限时把更早的对话压成一条摘要写回 transcript之后请求只带「摘要 近期消息」。reserveTokensFloor是给摘要本身留的余量别设太小否则摘要还没写完窗口就满了。3.2 config.toml工具注册与循环控制[agent] name clawdbot-research workspace ~/clawd [tools.allow] names [bash, read_file, write_file, memory_search, memory_get] [tools.bash] enabled true timeoutMs 30000 maxOutputChars 6000 denyPatterns [rm -rf /, shutdown, reboot] [tools.memory_search] enabled true indexPath ~/.clawdbot/memory/default.sqlite topK 5 hybridBm25 true [loop] onToolError return_to_model maxConsecutiveErrors 3tools.allow是白名单只放这个 Agent 真正需要的工具。ClawdBot 里工具定义在src/agents/tools/通过pi-tool-definition-adapter转成ToolDefinition传给createAgentSession。你自己写插件时用api.registerTool({ name, description, parameters, execute })注册是否对某 Agent 开放由tools.allow/tools.deny控制。onToolError return_to_model是关键设计。工具执行失败时不要把异常直接抛给用户而是把错误信息作为 tool result 回传给模型让它自己决定重试还是换工具。这才是 Agent 循环该有的容错方式。maxConsecutiveErrors兜底连续错 3 次就停避免模型在坏工具上反复撞墙。提示denyPatterns只是基础防护别把它当安全边界。真正的权限控制应该在工具实现层做比如 bash 工具只允许在指定目录下执行写文件工具限制路径前缀。配置里的模式匹配容易被绕过。4. 验证循环闭合三个可复现的本地动作配置写完不代表循环能跑通。下面三个动作从简到繁逐个验证「消息 → 工具调用 → 执行 → 结果回传 → 再决策」这条链路。4.1 动作一单轮工具调用是否返回 tool_calls先构造一个必然触发工具调用的请求确认模型侧没问题。用 curl 直接打 TaoToken 的接口export TAOTOKEN_API_KEY你的Key curl -s https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d { model: claude-sonnet-4-20250514, messages: [ {role: user, content: 列出当前目录下的文件} ], tools: [ { type: function, function: { name: bash, description: 在本地执行 shell 命令并返回输出, parameters: { type: object, properties: { command: {type: string, description: 要执行的命令} }, required: [command] } } } ] } | jq .choices[0].message.tool_calls期望结果是返回一个数组里面有id、function.name为bash、function.arguments是{command: ls}这样的 JSON 字符串。如果这里是 null说明模型没触发工具调用检查tools字段格式或换个模型再试。这一步过了说明「模型 ↔ 工具定义」的协议层是通的。4.2 动作二手动回传 tool result 看模型是否接续拿到上一步的tool_calls后本地执行命令把结果按 tool result 格式塞回 messages再请求一次curl -s https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d { model: claude-sonnet-4-20250514, messages: [ {role: user, content: 列出当前目录下的文件}, {role: assistant, content: null, tool_calls: [ {id: call_abc123, type: function, function: {name: bash, arguments: {\command\: \ls\}}} ]}, {role: tool, tool_call_id: call_abc123, content: README.md\nsrc\npackage.json} ], tools: [ /* 同上 */ ] } | jq .choices[0].message关键看两点tool_call_id必须和 assistant 消息里的id严格对应role必须是tool。如果模型这次返回的是自然语言总结比如「当前目录有 README.md、src 和 package.json」而不是新的tool_calls说明循环正常闭合了。如果它又发起一次ls那就是结果没被正确识别多半是tool_call_id对不上。4.3 动作三跑通多轮循环并观察停止条件前两步是手动挡这一步用代码把循环跑起来。核心逻辑就是一个 whileimport os, json, subprocess, requests API https://taotoken.net/api/v1/chat/completions HEADERS { Authorization: fBearer {os.environ[TAOTOKEN_API_KEY]}, Content-Type: application/json, } TOOLS [{ type: function, function: { name: bash, description: 在本地执行 shell 命令, parameters: { type: object, properties: {command: {type: string}}, required: [command], }, }, }] def run_bash(command: str) - str: try: out subprocess.run(command, shellTrue, capture_outputTrue, textTrue, timeout30) return (out.stdout out.stderr)[:6000] or (无输出) except Exception as e: return f执行失败: {e} messages [{role: user, content: 统计当前目录下 .py 文件的行数总和}] for turn in range(12): resp requests.post(API, headersHEADERS, json{ model: claude-sonnet-4-20250514, messages: messages, tools: TOOLS, }, timeout60).json() msg resp[choices][0][message] messages.append(msg) tool_calls msg.get(tool_calls) if not tool_calls: print(f[第{turn1}轮] 循环结束最终回复{msg[content]}) break for tc in tool_calls: args json.loads(tc[function][arguments]) print(f[第{turn1}轮] 调用 {tc[function][name]}: {args}) result run_bash(args[command]) messages.append({ role: tool, tool_call_id: tc[id], content: result, }) else: print(达到最大轮数仍未停止检查停止条件)跑起来后观察输出。正常情况是第 1 轮模型调bash执行find . -name *.py | xargs wc -l拿到结果后第 2 轮直接给出总行数并停止。如果轮数一路涨到 12说明模型在反复调工具回去检查toolResultMaxChars是不是截断太狠导致模型看不到关键信息或者工具描述写得不够清楚让模型不确定该调哪个。5. 本篇常见错排查5.1 tool_call_id 不匹配导致结果被忽略最常见的坑。assistant 消息里的tool_calls[].id和 tool 消息里的tool_call_id必须一字不差。有些框架会自动生成新 id或者把多个工具调用的结果合并成一条消息都会让模型认为「这个结果不是我要的」。排查方法在循环里打印每次追加的tool_call_id和上一轮 assistant 消息里的 id 逐个比对。5.2 工具结果太长把 context 撑爆终端命令输出几万字符是常事。如果不截断直接回传下一轮请求可能直接超 context 限制报错或者模型因为信息过载而忽略关键部分。配置里的toolResultMaxChars就是干这个的6000 到 8000 字符对多数场景够用。截断时记得在末尾加一句「(输出已截断)」让模型知道信息不完整它可能会换个更精确的命令重试。5.3 模型不返回 tool_calls 而是直接编答案有时候模型会跳过工具调用直接根据训练知识编一个答案。这通常是因为工具描述不够明确或者 system prompt 里没强调「涉及本地信息必须先调工具」。ClawdBot 的做法是在 system prompt 里加一节 Memory Recall要求模型在回答与过往工作、决策、日期相关的问题前先做memory_search。你可以照搬这个思路在 system prompt 里明确写「任何涉及本地文件、目录、命令的问题必须先调用对应工具获取真实结果不得凭猜测回答」。5.4 循环停不下来模型反复调同一个工具通常是结果没让它满意。可能原因工具返回了错误但格式不清晰模型以为是正常结果继续试或者工具描述里的参数说明有歧义模型每次传的参数都略有不同但都不对。排查时把每轮的tool_calls参数和 tool result 都打出来看模型是不是在原地打转。maxConsecutiveErrors和maxLoopTurns是兜底但根治还得靠把工具描述和错误信息写清楚。5.5 多厂商格式差异导致适配层出错如果你同时用 OpenAI 和 Anthropic 的模型注意tool_calls的字段结构不同。OpenAI 是message.tool_calls[]Anthropic 是content[]里的tool_useblock。pi-ai这类适配层就是干这个的但如果你自己手写请求构造很容易在切换模型时漏改。用 TaoToken 统一接口的好处在这里体现请求和响应格式统一成 OpenAI 兼容风格切换模型不用动循环代码。6. 把循环结构复用到你自己的 Agent 项目这套结构的核心就三件事工具定义用 JSON Schema 描述清楚循环里严格维护tool_call_id的对应关系工具结果回传时控制长度并保留错误信息。ClawdBot 把这三件事封装在pi-coding-agent的 session 和pi-tools.ts里你自己实现时不必照搬它的分层但循环的骨架是一样的。短期记忆靠 transcript 持久化加 compaction 控制窗口长期记忆靠磁盘上的 Markdown 文件加memory_search/memory_get按需取回。这两块和 Function Calling 循环是正交的可以分开迭代。先把循环跑闭合再往上加记忆和技能出问题时排查范围小得多。验证循环是否闭合最直接的办法就是第 4 节那三个动作先确认模型返回 tool_calls再手动回传结果看它接不接续最后用 while 循环跑多轮看停止条件。任何一步卡住问题就定位在那一段不用满仓库找。接入和排障细节可以对照文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite Key 管理在 API Keys 页面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。
