手写一个 Tool-Calling Agent:为什么要先不用框架
框架能让你十分钟搭出一个 Agent demo。但当我被问到「任务拆解和结果校验你是怎么做的」时,我发现自己答不上来------因为我根本没写过那个循环。于是我从零手写了一遍,并用 51 条任务的评测集把每一次改动量化。这篇文章记录其中最关键的四个决策。
一、先看循环长什么样# 手写一个 Tool-Calling Agent:为什么要先不用框架
框架能让你十分钟搭出一个 Agent demo。但当我被问到「任务拆解和结果校验你是怎么做的」时,我发现自己答不上来------因为我根本没写过那个循环。于是我从零手写了一遍,并用 51 条任务的评测集把每一次改动量化。这篇文章记录其中最关键的四个决策。
一、先看循环长什么样
一个工具调用型 Agent 的核心就是一个循环:模型决定要不要调工具 → 程序生成调用参数并执行 → 把结果作为观测回填 → 模型继续判断。看起来简单,真正的问题是每一步出错怎么办。
python
for step in range(1, max_steps + 1):
msg = llm.chat(messages, SPECS)
calls = msg.get("tool_calls") or []
if calls:
for raw in calls:
call, err = normalize(raw) # 解析参数
if verify: # ← 可开关的加固
ok, why = validate_args(name, args)
if not ok:
回填错误信息让它重发;continue
out = execute(name, args) # 真正执行
messages.append(观测)
else:
return 最终答案
二、决策一:参数错误不能抛异常
模型填错参数非常常见------我见过它给 calculator 传自然语言"十五加二十七",给 file_write 只传 path 不传 content。
新手写法是直接让工具抛 KeyError,结果整个 loop 崩掉,用户看到一堆堆栈。正确的做法是把失败变成信息:用 JSON Schema 校验,把缺哪个字段、类型应该是什么,拼成一句自然语言回喂给模型,让它重新发一次。
带来思考:工具边界应该是「防御性的」而非「契约性的」。你不能假设调用方(哪怕是 GPT)会遵守约定。
三、决策二:结果也要校验
参数对了不代表结果对。工具返回空数组、返回 null、或者异常字符串,如果不加判断就塞进上下文,模型会把 "查询无结果" 当成事实继续推理,最后给出一个自信但错误的答案。
我的处理是三态判定:正常结果直接用;空结果带上提示重试一次;报错则把错误详情回传,允许它换个策略。关键是让模型知道刚才那次是失败的,而不是假装无事发生。
四、决策三:必须有步数护栏
不加限制的话,模型可能陷入「重试同一个失败动作」的死循环。我设了 max_steps=8,超出就强制终止并记录失败原因。这不是为了省 token(当然也省),更重要的是让「超时」成为一个可观测的失败类别------在我的失败归因里,这类占比一度最高。
五、决策四:把「校验」做成可开关,才能证明它有用
很多人会说「我加了校验,效果好多了」。但这话没有证据支撑。我的做法是把校验做成 verify 参数,在同一套评测集上跑两次:
| 版本 | 任务成功率 | 工具调用错误率 |
|---|---|---|
| baseline(无校验) | 92.2% | 5.9% |
| hardened(参数+结果校验) | 92.2% | 7.3% |
只有当数字是跑出来的、而不是感觉出来的,简历上那句话才站得住。这一版我用的是 DeepSeek(deepseek-chat):强模型本身就把成功率拉到 92.2%,两版几乎持平------但这恰恰说明「校验」的定位是兜底 而非救命:它拦下参数 / 结果类错误,让上下文不被脏数据污染,而不靠它硬拉成功率。如果你的模型更弱(或换成 mock 规划器),两版的差距会立刻拉开,那时 Δ 才是真证据。
六、评测集怎么搭才不会白搭
三条经验:
- 答案要跟着数据走 。所有涉及数据库或日期的标准答案,我在
gen_tasks.py里直接查 SQLite 现算,而不是手写成常量。否则代码一改,整套答案就失效。 - 要分层。20 条单步 + 20 条两步 + 11 条三步以上,这样你能看出模型到底卡在哪一层------我的结果显示,「泪」往往发生在跨工具的第二步而不是复杂的第一步。
- 要为「不做什么」设题。我特意放了「今天是几号」这种题,专门检验模型会不会不调工具、直接凭训练记忆回答(这就是幻觉)。
七、失败归因比数字更重要
每次跑完评测,我会把失败任务按类型归档:参数错误 / 执行报错 / 未调工具(幻觉)/ 文件未写出 / 步数耗尽。没有归因的评测只是数字,有了归因才知道下一步改哪里 。比如我发现「文件未写出」占比高,追下去发现是模型把路径写成了 output/report.md,而我的沙箱强制 basename 归一化------这个问题加个提示词就解决了。
小结
手写一遍 Agent 循环的直接收益,不是得到一个比框架更好的 Agent(显然不是),而是获得一组可以回答追问的细节:参数错了怎么办、结果空了怎么办、步数炸了怎么办、以及你怎么知道改动是有效的。这些细节在面试里比"我熟悉 LangChain"有说服力得多。
一个工具调用型 Agent 的核心就是一个循环:模型决定要不要调工具 → 程序生成调用参数并执行 → 把结果作为观测回填 → 模型继续判断。看起来简单,真正的问题是每一步出错怎么办。
python
for step in range(1, max_steps + 1):
msg = llm.chat(messages, SPECS)
calls = msg.get("tool_calls") or []
if calls:
for raw in calls:
call, err = normalize(raw) # 解析参数
if verify: # ← 可开关的加固
ok, why = validate_args(name, args)
if not ok:
回填错误信息让它重发;continue
out = execute(name, args) # 真正执行
messages.append(观测)
else:
return 最终答案
二、决策一:参数错误不能抛异常
模型填错参数非常常见------我见过它给 calculator 传自然语言"十五加二十七",给 file_write 只传 path 不传 content。
新手写法是直接让工具抛 KeyError,结果整个 loop 崩掉,用户看到一堆堆栈。正确的做法是把失败变成信息:用 JSON Schema 校验,把缺哪个字段、类型应该是什么,拼成一句自然语言回喂给模型,让它重新发一次。
带来思考:工具边界应该是「防御性的」而非「契约性的」。你不能假设调用方(哪怕是 GPT)会遵守约定。
三、决策二:结果也要校验
参数对了不代表结果对。工具返回空数组、返回 null、或者异常字符串,如果不加判断就塞进上下文,模型会把 "查询无结果" 当成事实继续推理,最后给出一个自信但错误的答案。
我的处理是三态判定:正常结果直接用;空结果带上提示重试一次;报错则把错误详情回传,允许它换个策略。关键是让模型知道刚才那次是失败的,而不是假装无事发生。
四、决策三:必须有步数护栏
不加限制的话,模型可能陷入「重试同一个失败动作」的死循环。我设了 max_steps=8,超出就强制终止并记录失败原因。这不是为了省 token(当然也省),更重要的是让「超时」成为一个可观测的失败类别------在我的失败归因里,这类占比一度最高。
五、决策四:把「校验」做成可开关,才能证明它有用
很多人会说「我加了校验,效果好多了」。但这话没有证据支撑。我的做法是把校验做成 verify 参数,在同一套评测集上跑两次:
| 版本 | 任务成功率 | 工具调用错误率 |
|---|---|---|
| baseline(无校验) | X% | Y% |
| hardened(参数+结果校验) | X+Δ% | Y-Δ% |
只有当 Δ 是跑出来的、而不是感觉出来的,简历上那句话才站得住。
六、评测集怎么搭才不会白搭
三条经验:
- 答案要跟着数据走 。所有涉及数据库或日期的标准答案,我在
gen_tasks.py里直接查 SQLite 现算,而不是手写成常量。否则代码一改,整套答案就失效。 - 要分层。20 条单步 + 20 条两步 + 11 条三步以上,这样你能看出模型到底卡在哪一层------我的结果显示,「泪」往往发生在跨工具的第二步而不是复杂的第一步。
- 要为「不做什么」设题。我特意放了「今天是几号」这种题,专门检验模型会不会不调工具、直接凭训练记忆回答(这就是幻觉)。
七、失败归因比数字更重要
每次跑完评测,我会把失败任务按类型归档:参数错误 / 执行报错 / 未调工具(幻觉)/ 文件未写出 / 步数耗尽。没有归因的评测只是数字,有了归因才知道下一步改哪里 。比如我发现「文件未写出」占比高,追下去发现是模型把路径写成了 output/report.md,而我的沙箱强制 basename 归一化------这个问题加个提示词就解决了。
小结
手写一遍 Agent 循环的直接收益,不是得到一个比框架更好的 Agent(显然不是),而是获得一组可以回答追问的细节:参数错了怎么办、结果空了怎么办、步数炸了怎么办、以及你怎么知道改动是有效的。这些细节在面试里比"我熟悉 LangChain"有说服力得多。