从0打造编码Agent 阅读 + 实验 ≈ 30 分钟 页内实验 ×3
A1 循环:模型只会吐文字,它怎么改你的文件?什么时候停? 第 1 课

模型只会吐文字,它怎么「动手」?

模型怎么动手
不给工具、让模型把修复命令写在回复里再用正则抠出来:同一个模型 8 次,测试一次都没通过。改用工具调用协议:模型写下结构化的调用,harness 执行、把结果作为一条消息回填。它在模型眼里仍然只是一段 token——所以执行、出错处理、回填,全在模型外面。
❓

本课一问:语言模型每次只做一件事:给一段文字,续写下一段文字。可一个编码 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 代码抠出来,在仓库里执行,再跑测试。

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案不给工具:模型把命令写进回复,正则抠出来执行
不给工具:让模型把修复命令写在回复里,程序用正则抠出来执行T1a · Qwen/Qwen3-8B-MLX-4bit · 2026-09-26 · N = 8
0 / 8执行后测试通过
8 / 8正则抠到了东西
6抠出的命令在本机直接报错
0 / 6把这些命令改成 BSD 写法再跑,测试通过
  • 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 字。

🎯

试三件事:① 点开 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 的执行、回填的那条消息。

一次工具调用的完整协议:请求 → 回复 → harness 执行 → 回填 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 消息。

一次工具调用就是下面这两条消息。逐项点开:

一次工具调用 = 模型写的一条 + harness 回填的一条(OpenAI Chat Completions 格式,取自上面的真实运行)
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
Anthropic Messages API 是同一回事的另一种写法:调用是 assistant 消息里的 tool_use 内容块(input 是对象,不是字符串),结果是下一条 user 消息里的 tool_result 块,stop_reason 为 tool_use。下面 learn-claude-code 的 s01 用的就是这种格式。

learn-claude-code 的第一课把同一个协议写成了 30 行,用的是 Anthropic 的格式:

learn-claude-code s01:tool_use 进、tool_result 出 learn-claude-code@0dcafa2:s01_agent_loop/code.py:94-117需联网 MIT
94 # Append assistant turn
95 messages.append({"role": "assistant", "content": response.content})
96
97 # If the model didn't call a tool, we're done
98 tool_calls = [
99 block for block in response.content if block.type == "tool_use"
100 ]
101 if not tool_calls:
102 return
103
104 # Execute each tool call, collect results
105 results = []
106 for block in tool_calls:
107 print(f"\033[33m$ {block.input['command']}\033[0m")
108 output = run_bash(block.input["command"])
109 print(output[:200])
110 results.append({
111 "type": "tool_result",
112 "tool_use_id": block.id,
113 "content": output,
114 })
115
116 # Feed tool results back, loop continues
117 messages.append({"role": "user", "content": results})
👆 带下划线的行可以点:点哪行,讲哪行。

极小真数字:一次往返在请求里占多少

每一次请求都要把工具定义和此前的全部消息整份发出去(第 2 课细讲)。第 1 轮到第 2 轮,请求变长的部分,正好是这一次往返新增的两条消息:

pd-v2-unlimited-s3:第 2 次请求的 990 个 token 是怎么来的(Qwen3-8B 的 tokenizer 与 chat template 逐条归属,与服务端 usage 一致)
=
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
逐项相加:685 + 202 + 57 + 3 + 23 + 20 = 990,与服务端报告的 990 相同。
⚠
常见误解:「模型执行了命令」「模型调用了工具」。

模型从头到尾只做了一件事:生成 token。上面那条 list_dir 调用,是它写下的一段文字;真正列目录的,是 harness 在你的机器上起的进程。所以执行、出错处理、结果回填全在 harness 里——参数解析失败怎么办、执行报错怎么说、要不要先问人(A3)、在不在沙箱里跑(A4),都是 harness 的代码,不是模型的能力。「模型调用了工具」只能当口语用。

回扣 LLM 课:tool call 也只是 token

LLM 课 C12 第 8 课说过「工具调用也只是 token」。现在可以把它拆开看了。上面第 1 轮那条回复,在第 2 次请求里被模板写回成下面这 18 个 token:

<tool_call>151657⏎198{"4913name606":788 "330list1607_dir4334",497 "330arguments16370":788 {"5212path2343":788 "."22760}}⏎11248</tool_call>151658

首尾两个是词表里的特殊 token(<tool_call> 的 id 是 151657,</tool_call> 是 151658),中间那段 JSON 就是普通 token,和模型写的任何一句话一样,是一个一个采样出来的。所谓「原生工具调用」,是模型在训练时学会了这个格式,服务端的解析器(这里是 mlx_lm.server)再把标签里的 JSON 切出来,转成 tool_calls 字段交给 harness。

所以协议并没有让输出变得「一定合法」:本课项目 24 次原生调用的运行里,服务端解析器有 7 次遇到参数 JSON 不合法(模型在正则表达式里写了没转义的反斜杠),把整条调用丢掉,只回一个空的 tool_calls 。作为对照,项目也实现了「在提示词里约定一个 JSON 格式、自己解析」的方式,2 次运行里 13 个调用、0 次解析失败,却有 6 次回复写满上限被截断 。协议把「格式归谁负责」讲清楚了,但错误并没有消失:它们挪到了 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 不必从散文里猜命令;结果作为消息回填,下一次生成就以它为条件
③ 实验上呢?本课项目:不给工具、正则抠命令,0 / 8 次通过;给了协议,解析失败并没有消失(服务端丢弃 7 次),只是挪到了 harness 能处理的地方

演进地图:你在这里

模型只会吐文字,怎么动手、什么时候停:每一代都被上一代的某个真数字逼出来
  1. 解决了不需要任何协议:模型本来就会写命令。
    卡在真实基线 T1a:同一个模型、同一个任务跑 8 次,执行抠出来的命令后测试通过 0 / 8;其中 6 次抠出的 GNU 写法 sed -i 在 macOS 上直接报错,改成 BSD 写法再跑也 0 次通过——一次回复只看得见第一处 bug。
  2. 第一代 工具调用协议:模型写结构化的调用,harness 执行后回填 tools 参数 + tool_calls + role: tool 你在这里
    解决了格式由模型训练与服务端解析器负责,harness 拿到的是 name + JSON 参数;执行结果以 role: tool 回填,模型能看见后果。
    卡在一次调用只能走一步:贯穿任务的真实成功运行,最少也用了 6 轮(读 → 改 → 测出第二处 → 改 → 再测)。协议之下仍然是文字:24 次原生调用的运行里,服务端解析器 7 次把 JSON 不合法的调用整条丢掉。
  3. 朴素 固定调用 3 轮 for _ in range(3)
    解决了能连续走几步了,而且一定会停。
    卡在贯穿任务的 20 次真实运行里,3 轮以内收工的 0 次;只调 3 轮,6 次成功里有 6 次变成失败(重放前 3 轮的 edit 后实跑验收)。
  4. 第二代 循环到模型不再调工具为止 CoreCoder、learn-claude-code s01
    解决了轮数由任务决定,不由写死的数字决定;状态只有一个 messages[],每轮整份重发。
    卡在「没有工具调用」不等于「做完了」:harness v1 有 3 次运行因服务端丢了调用而误停;26 次里模型自己停下 21 次,其中 13 次验收没通过。
  5. 第三代 双重校验 + 截断防护 + 可枚举的退出原因 OpenCode、Pi、Hermes;本课项目 v2
    解决了finish_reason 和消息体都看:说要调工具却没给调用、被截断、报了不认识的结束原因,都不当成「做完了」。
    卡在补救也会失败:pd-v2-nothink-s1 连续 3 次发出被丢弃的调用,提醒 2 次无效后以 dropped_tool_call 停;而且它不回答「要不要强制停」。
  6. 第四代 上限与预算:硬上限 / 收尾提示 / 不设上限靠压缩兜底 / 预算加总结调用 CoreCoder 50 · OpenCode steps · Codex 不设 · Hermes 预算
    解决了死循环有了兜底;到上限时可以返回显式原因,或让模型先收尾。
    卡在上限 4:2 次运行都在第 4 轮刚改完第一处时被截断,模型毫不知情;不设上限的 6 次全部自己停下(最长 8 轮)。重放:上限 6 碰巧救回 2 次(截在重复 edit 之前),而最长那次成功运行要到第 19 轮才修好。
  7. 岔路 双层循环:干活时插话、做完后追加 Pi steering / follow-up;Codex 任务层
    解决了用户不必等整次运行结束才能纠偏。
    卡在插话时机本身是设计决策:同一条事件序列里,4 个生产循环把插话送进下一次请求,3 个教学 / 项目循环要等这次运行结束。
  8. 岔路 不用框架的自动多步,自己驱动循环 OpenCode 调 streamText 不传 stopWhen
    解决了压缩检查、提醒注入、权限中断恢复都能插在两次调用之间。
    卡在循环要自己维护:Codex 把它拆成四层(submission / task / turn / stream),教学蓝本只写了其中一层。
  9. 岔路 独立评估器决定能不能停 learn-claude-code s17 · Hermes goals · Codex Stop hook
    解决了「模型说做完了」交给另一个没有工具的模型核对。
    卡在评估器也会错:Hermes 给目标循环设了 20 轮上限(pin 56dc01d9 config_defaults.py:1801),注释写明是防评估器「假阴性」;每轮多一次模型调用。
数字口径:本课项目 miniagent 的真实运行(Qwen3-8B-MLX-4bit,2026-09-26;单一模型、单一任务,只说明现象会发生,不估计频率);「截在第 N 轮」的验收结果是重放那次运行前 N 轮的 edit 后实跑测试得到的;插话一行来自 8 个终止谓词在同一条构造事件序列上的判定(a01-termination.json)。

一句话

📌
模型只提出调用,harness 负责执行。
🚦

先停在这里:记住两条消息——模型写的 tool_calls,harness 回填的 role: tool——和「模型从不执行任何东西」。
再深一层:去 A2 的岔路课一次工具调用的往返,看四条泳道里的逐轮回放和相邻请求的 diff。

下一个缺口

一次调用只能动一步,跑测试、读文件、改、再跑,怎么串起来? → 第 2 课:一次调用不够,怎么让它连续干活?

上面 T1a 的失败还剩第二层没解决:模型看不见自己那条命令的后果。第 2 课把「请求 → 执行 → 回填」接成一个循环。

你可能会问