模型只会吐文字,它怎么「动手」?
本课一问:语言模型每次只做一件事:给一段文字,续写下一段文字。可一个编码 agent 要读文件、跑测试、改代码。只会吐文字的模型,怎么「动手」?
这一课先不碰:怎么连续走很多步(第 2 课);什么时候停(第 3、4 课);模型怎么说清「改哪一行」(A2);它要执行危险命令怎么办(A3)。
从已有出发
LLM 课结束时(C12 第 8 课)留下一个缺口:模型已经会写出一段「调用工具」的文字,然后呢?谁去解析它、执行它、把结果送回去?这一章补的就是这一层——包在模型外面的那个程序,叫 harness。
全课用同一个贯穿任务:让本课项目任务仓里失败的 test_parse_date 通过。要改的 dateparse.py 埋了两处 bug(DD/MM/YYYY 分支日月解包反了;月份名分支少了 + 1),而且它们按顺序暴露:修掉第一处、重跑测试,才看得见第二处。这一章所有的真实数字,都来自本课项目 miniagent 用本机的 Qwen3-8B 修这个任务的记录。
先自己设计:不给工具,让模型把命令写出来
最省事的办法:不发明任何协议。把测试输出和源文件一起给模型,让它回一条修复命令;harness 用正则把回复里的第一段 shell 代码抠出来,在仓库里执行,再跑测试。
- ① 朴素方案
- ② 它在哪失败
- ③ 改进方案
- 6 次 · GNU 写法的
sed -i(-i 后面直接跟替换表达式):macOS 自带的 BSD sed 把替换表达式当成了备份后缀、把文件名当成了 sed 脚本,报错退出,文件没动 - 1 次 · 回复写满 4096 个 token 被截断,正则只抠到一段行内代码
test_parse_date,它不是命令 - 1 次 · 命令能跑、文件改了,但只修了第一处 bug:第二处要等测试重跑之后才看得见
录制:04-实践项目/scripts/record_t1a.py;提示词要模型「回复一条修复命令,它会被复制出来在仓库根目录执行」;抽取规则 first ```(bash|sh|shell|zsh|console|<none>) block, else first inline `code`; regex: ```([\w+-]*)[^\n]*\n(.*?)```;命令在 Seatbelt 沙箱里的任务仓副本上执行,之后跑任务的验收测试。模型回复截取约 1500 字。
失败有两层。第一层是格式:命令混在说明文字里,写法取决于模型的习惯(GNU 还是 BSD 的 sed),抠错了、抠到半截、抠到的根本不是命令,harness 都只能照单全收。第二层是只有一步:模型看不见自己那条命令的后果,也就看不见第一处修好之后才暴露的第二处。
工具调用协议先解决第一层:请求里带上一份工具清单(名字、说明、参数的 JSON Schema),模型要做事时,不再写一段散文,而是写一个结构化的调用——工具名 + JSON 参数;harness 解析、执行,把结果作为一条新消息放回对话,再请求一次。第二层(连续多步)是第 2 课的问题。
sed -i,在 macOS 上直接报错;1 次回复写满上限被截断,正则抠到的不是命令;1 次命令能跑,却只修了第一处。把那 6 条 sed 改成 BSD 写法再跑,仍然 0 次通过——一次回复只看得见第一处 bug。 试三件事:① 点开 seed 1:回复里先有一段 python 代码块,正则跳过了它、抠出后面那条 bash 块里的 GNU 写法 sed -i,退出码 1;② 点开 seed 2:回复写满 4096 token 被截断,正则只抠到一段行内代码 test_parse_date,退出码 127;③ 点开 seed 6:它用了 BSD 写法、命令成功、文件改了——测试照样没过,因为第二处 bug 这时才露出来。
改进:工具调用协议
下面把贯穿任务的一次真实运行(pd-v2-unlimited-s3,最后验收通过)的前两轮,逐步摊开成线上的样子:请求体、回复、harness 的执行、回填的那条消息。
数据:本课项目 miniagent 的真实运行 pd-v2-unlimited-s3(harness v2 · A1 无轮数上限(seed 3),录制于 2026-09-26;模型 Qwen3-8B-MLX-4bit,本机 mlx_lm.server)。每条消息的 token 用该模型的 tokenizer 与 chat template 逐条归属(a01-loop-data.json),2 轮请求的合计与服务端 usage.prompt_tokens 逐轮一致。请求体与回复 JSON 按 trace 记录的字段重建,未记录的字段(响应 id、时间戳等)省略。
试三件事:① 走到「第 1 次请求」,切到「模型实际读到的文本」:6 个工具的完整定义被 chat template 写进了 system 段(紫色),每一轮都在;② 走到「回复」:finish_reason 是 tool_calls,tool_calls[0].function.arguments 是一个字符串,不是对象;③ 走到「harness 执行工具并回填」:模型此刻什么也没做,是 harness 在本机执行了 list_dir,再追加一条带 tool_call_id 的 role: tool 消息。
一次工具调用就是下面这两条消息。逐项点开:
learn-claude-code 的第一课把同一个协议写成了 30 行,用的是 Anthropic 的格式:
极小真数字:一次往返在请求里占多少
每一次请求都要把工具定义和此前的全部消息整份发出去(第 2 课细讲)。第 1 轮到第 2 轮,请求变长的部分,正好是这一次往返新增的两条消息:
模型从头到尾只做了一件事:生成 token。上面那条 list_dir 调用,是它写下的一段文字;真正列目录的,是 harness 在你的机器上起的进程。所以执行、出错处理、结果回填全在 harness 里——参数解析失败怎么办、执行报错怎么说、要不要先问人(A3)、在不在沙箱里跑(A4),都是 harness 的代码,不是模型的能力。「模型调用了工具」只能当口语用。
回扣 LLM 课:tool call 也只是 token
LLM 课 C12 第 8 课说过「工具调用也只是 token」。现在可以把它拆开看了。上面第 1 轮那条回复,在第 2 次请求里被模板写回成下面这 18 个 token:
首尾两个是词表里的特殊 token(<tool_call> 的 id 是 151657,</tool_call> 是 151658),中间那段 JSON 就是普通 token,和模型写的任何一句话一样,是一个一个采样出来的。所谓「原生工具调用」,是模型在训练时学会了这个格式,服务端的解析器(这里是 mlx_lm.server)再把标签里的 JSON 切出来,转成 tool_calls 字段交给 harness。
所以协议并没有让输出变得「一定合法」:本课项目 24 次原生调用的运行里,服务端解析器有 7 次遇到参数 JSON 不合法(模型在正则表达式里写了没转义的反斜杠),把整条调用丢掉,只回一个空的 tool_calls 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条项目实测:24 次原生工具调用的运行,共 126 个被执行的调用、7 次整条丢弃;单一模型(Qwen3-8B 4bit)、单一推理服务。 出处待补 。作为对照,项目也实现了「在提示词里约定一个 JSON 格式、自己解析」的方式,2 次运行里 13 个调用、0 次解析失败,却有 6 次回复写满上限被截断 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条项目实测,样本很小:只能说明两种方式都会出问题,不能比较谁更稳。两种方式的详细对照在 A8。 出处待补 。协议把「格式归谁负责」讲清楚了,但错误并没有消失:它们挪到了 harness 要处理的地方(第 3 课)。
🔍 协议是谁定的?为什么各家长得不一样?
工具调用协议是模型 API 提供方定义的:OpenAI Chat Completions 用 tools + tool_calls + role: tool,Anthropic Messages 用 tools + tool_use / tool_result 内容块,OpenAI Responses 用 function_call / function_call_output 输出项(Codex 用的就是它)。底下模型各自用自己的 chat template 把工具说明写进提示词、把调用写成特殊标记包着的文本;本课项目的 Qwen3 用的是 <tool_call> 标签。harness 对接哪一家,就说哪一家的格式——这是 A8「换模型」要处理的第一件事。
证据三问
| 问 | 本课的回答 |
|---|---|
| ① 它解决什么问题? | 让只会生成文字的模型,可靠地把「要做什么」交给一个能执行的程序,并看到执行的结果 |
| ② 为什么可能有效? | 格式由训练与解析器约定,harness 不必从散文里猜命令;结果作为消息回填,下一次生成就以它为条件 证据等级 · 有理论动机 有数学或机制上的理由说明它可能有效,但没有证明它一定更好。最后是否采用,仍然要看实验。 本条协议的机制是官方文档直述(调研满票);「比从散文里抠命令更可靠」是设计动机,不是被证明的结论。 出处Claude Agent SDK · agent loop(官方) ↗需联网 |
| ③ 实验上呢? | 本课项目:不给工具、正则抠命令,0 / 8 次通过;给了协议,解析失败并没有消失(服务端丢弃 7 次),只是挪到了 harness 能处理的地方 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条项目实测:单一模型、单一任务、次数很少;说明现象存在,不估计比例。 出处待补 |
演进地图:你在这里
- 解决了不需要任何协议:模型本来就会写命令。卡在真实基线 T1a:同一个模型、同一个任务跑 8 次,执行抠出来的命令后测试通过 0 / 8;其中 6 次抠出的 GNU 写法
sed -i在 macOS 上直接报错,改成 BSD 写法再跑也 0 次通过——一次回复只看得见第一处 bug。 - 解决了格式由模型训练与服务端解析器负责,harness 拿到的是 name + JSON 参数;执行结果以
role: tool回填,模型能看见后果。卡在一次调用只能走一步:贯穿任务的真实成功运行,最少也用了 6 轮(读 → 改 → 测出第二处 → 改 → 再测)。协议之下仍然是文字:24 次原生调用的运行里,服务端解析器 7 次把 JSON 不合法的调用整条丢掉。 - 解决了能连续走几步了,而且一定会停。卡在贯穿任务的 20 次真实运行里,3 轮以内收工的 0 次;只调 3 轮,6 次成功里有 6 次变成失败(重放前 3 轮的 edit 后实跑验收)。
- 解决了轮数由任务决定,不由写死的数字决定;状态只有一个 messages[],每轮整份重发。卡在「没有工具调用」不等于「做完了」:harness v1 有 3 次运行因服务端丢了调用而误停;26 次里模型自己停下 21 次,其中 13 次验收没通过。
- 解决了finish_reason 和消息体都看:说要调工具却没给调用、被截断、报了不认识的结束原因,都不当成「做完了」。卡在补救也会失败:pd-v2-nothink-s1 连续 3 次发出被丢弃的调用,提醒 2 次无效后以
dropped_tool_call停;而且它不回答「要不要强制停」。 - 解决了死循环有了兜底;到上限时可以返回显式原因,或让模型先收尾。卡在上限 4:2 次运行都在第 4 轮刚改完第一处时被截断,模型毫不知情;不设上限的 6 次全部自己停下(最长 8 轮)。重放:上限 6 碰巧救回 2 次(截在重复 edit 之前),而最长那次成功运行要到第 19 轮才修好。
- 解决了用户不必等整次运行结束才能纠偏。卡在插话时机本身是设计决策:同一条事件序列里,4 个生产循环把插话送进下一次请求,3 个教学 / 项目循环要等这次运行结束。
- 解决了压缩检查、提醒注入、权限中断恢复都能插在两次调用之间。卡在循环要自己维护:Codex 把它拆成四层(submission / task / turn / stream),教学蓝本只写了其中一层。
- 解决了「模型说做完了」交给另一个没有工具的模型核对。卡在评估器也会错:Hermes 给目标循环设了 20 轮上限(pin 56dc01d9 config_defaults.py:1801),注释写明是防评估器「假阴性」;每轮多一次模型调用。
一句话
先停在这里:记住两条消息——模型写的 tool_calls,harness 回填的 role: tool——和「模型从不执行任何东西」。
再深一层:去 A2 的岔路课一次工具调用的往返,看四条泳道里的逐轮回放和相邻请求的 diff。
下一个缺口
一次调用只能动一步,跑测试、读文件、改、再跑,怎么串起来? → 第 2 课:一次调用不够,怎么让它连续干活?
上面 T1a 的失败还剩第二层没解决:模型看不见自己那条命令的后果。第 2 课把「请求 → 执行 → 回填」接成一个循环。
你可能会问
- 「原生工具调用和『提示词里约定 JSON』到底差在哪?」 差在格式归谁:原生方式的格式是模型训练时见过的,模板和解析器由模型方、服务方维护;提示词约定的格式是 harness 临时发明的,解析也归 harness。前者更稳,但解析失败时可能被服务端静默丢掉;后者能拿到原文、把具体错误告诉模型。A8 用两组真实运行对照。
- 「arguments 为什么是字符串,不直接给对象?」 因为它就是模型生成的一段文本:服务端只负责把它切出来,不保证它是合法 JSON。Anthropic 的格式在 API 层先解析成对象(input),但截断时照样可能不完整(第 3 课)。
- 「能不能让模型一次写一整段脚本,把几步都做完?」 这是 Code Mode 的思路,见 A2 的岔路课让模型写代码来调工具:它省的是往返次数,不省「看了结果再决定下一步」。