从0打造编码Agent 阅读 + 实验 ≈ 30 分钟 页内实验 ×3
A0 工作流还是 Agent:什么时候需要 agent,什么时候一段代码就够? 第 1 课

修好 test_parse_date,最朴素的程序怎么写?

最朴素的程序
不写任何循环:跑一次测试,把报错和源文件发给模型,要回一份完整的新文件,覆写,重跑。本课项目把这段程序在 4 个任务上真跑了一遍:修好 2 个。parse_date 只改了报错指着的那一行,第二处 bug 的报错要等第一处修好才出现;slugify 的模型答对了,却只回了一个函数片段,代码的兜底规则把片段当成整份文件写回,丢了 import re。
❓

本课一问:我们已经知道 bug 在 dateparse.py 里,测试也已经告诉我们哪里不对。要修好 test_parse_date,最朴素的程序怎么写?它需要「agent」吗?

这一课先不碰:让模型自己跑命令、读文件、连续走很多步(A1);怎么只改一处而不是整份重写(A2);它要执行危险命令怎么办(A3)。

从已有出发

LLM 课的最后一课(C12 第 8 课)把模型训练到会写出一段「调用工具」的文字。可在给它造一整套执行工具的 harness 之前,先退一步:这件事真的需要模型自己去调工具吗?也许一段普通的代码、调一次模型就够了。

从已有出发 · 累计数据流 点已学环节看它回答什么
每一轮循环都经过

全课的贯穿任务:让本课项目任务仓里失败的 test_parse_date 通过,测试文件不许改。dateparse.py 埋了两处 bug——DD/MM/YYYY 分支把日和月解包反了;月份名分支用 MONTHS.index() 拿到的是从 0 起的下标,少了 + 1。测试在第一条失败的断言处就停:现在它只报 datetime.date(2024, 5, 3) != datetime.date(2024, 3, 5),第二处要等第一处修好才看得见。

先自己设计:一段代码,调一次模型

最容易想到的程序:测试失败了,就把测试输出和源文件一起发给模型,请它回一份修好的完整文件;程序把文件写回去,再跑一次测试。每一步做什么、下一步是什么,都写在代码里;模型只被调用一次,只负责「给出新文件」。

本课项目的 miniagent/workflow.py 就是这么写的,发给模型的 system prompt 只有 4 行:

WORKFLOW_SYSTEM = """You fix bugs in a small Python repository. You cannot run commands or read other files:
everything you get is in the user message. For every file you change, write a line
`FILE: <path>` followed by a fenced code block containing the COMPLETE new content of that file.
Do not change test files."""
  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案单次调用:报错 + 源文件发给模型,要回整份新文件,覆写后重跑
workflow 轨逐步回放 · 代码与真实状态联动 这 5 步全由代码决定(decided_by = code)
本课项目 04-实践项目/miniagent/workflow.py(快照 2026-09-26 17:46)
89 def run(self, prompt, task_id="adhoc", meta=None):
90 tr = self.tracer
91 tr.event("run_start", task={"id": task_id, "prompt": prompt}, config=self.describe(), meta=meta or {})
92 protected_before = {f: (self.root / f).read_bytes() for f in self.protected if (self.root / f).exists()}
93
94 code, test_output = self._run_tests()
95 self._step_event("run_tests", exit_code=code, output=test_output[-4000:])
96
97 sources = collect_sources(self.root)
98 contents = {s: TextDoc.load(self.root / s).lf for s in sources}
99 self._step_event("collect_sources", files=sources, chars=sum(len(c) for c in contents.values()))
100
101 user = [f"Task: {prompt}", "", "Test output:", "```", test_output[-4000:].rstrip(), "```"]
102 for s in sources:
103 user += ["", f"FILE: {s}", "```python", contents[s].rstrip("\n"), "```"]
104 messages = []
105 self._add(messages, {"role": "system", "content": self.system_prompt})
106 self._add(messages, {"role": "user", "content": "\n".join(user)})
107
108 totals = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0, "cached_tokens": 0,
109 "compaction_tokens": 0}
110 rounds, stop_reason, error, final_text, sources_used = 0, "workflow_done", None, "", set()
111 self._step_event("call_model", messages=len(messages))
112 try:
113 kwargs = {"max_tokens": self.max_tokens} if self.max_tokens else {}
114 r = self.client.complete(messages, tools=None, **kwargs)
115 except ModelError as e:
116 r, stop_reason, error = None, "model_error", str(e)
117 if r is not None:
118 rounds = 1
119 for k in ("prompt_tokens", "completion_tokens", "total_tokens"):
120 totals[k] += r.usage.get(k) or 0
121 totals["cached_tokens"] += r.usage.get("cached_tokens") or 0
122 sources_used.add(r.usage_source)
123 tr.event("model_call", round=1, messages_sent=len(messages), usage=r.usage, usage_source=r.usage_source,
124 latency_ms=r.latency_ms, finish_reason=r.finish_reason, n_tool_calls=0,
125 content_chars=len(r.content), reasoning=r.reasoning)
126 self._add(messages, {"role": "assistant", "content": r.content})
127 final_text = r.content
128
129 written, rejected, diff_changed, chars = [], [], 0, 0
130 for path, new in parse_file_blocks(r.content, sources):
131 if path not in sources:
132 rejected.append(path)
133 continue
134 old = contents[path]
135 diff = [ln for ln in difflib.unified_diff(old.splitlines(), new.splitlines(), lineterm="", n=0)
136 if ln[:1] in "+-" and not ln.startswith(("+++", "---"))]
137 doc = TextDoc.load(self.root / path)
138 (self.root / path).write_bytes(doc.splice(0, len(doc.lf), new)) # 整文件覆写,保留 BOM/换行风格
139 written.append(path)
140 diff_changed += len(diff)
141 chars += len(new)
142 self._step_event("apply", written=written, rejected=rejected, diff_lines_changed=diff_changed,
143 chars_written=chars, completion_tokens=r.usage.get("completion_tokens"),
144 finish_reason=r.finish_reason)
145
146 code, out = self._run_tests()
147 self._step_event("rerun_tests", exit_code=code, output=out[-4000:])
148
149 intact = all((self.root / f).read_bytes() == b for f, b in protected_before.items())
150 code, out = self._run_tests()
151 verify = {"passed": code == 0 and intact, "tests_passed": code == 0, "protected_files_intact": intact,
152 "exit_code": code, "command": " ".join(self.verify_cmd), "output": out[-3000:]}
153 tr.event("verify", **verify)
154 wall_ms = tr.elapsed_ms()
155 tr.event("run_end", stop_reason=stop_reason, rounds=rounds, usage_totals=totals,
156 usage_sources=sorted(sources_used), wall_ms=wall_ms, success=verify["passed"],
157 final_text=final_text, error=error)
158 return RunResult(stop_reason, rounds, final_text, messages, totals, wall_ms, verify)

数据:本课项目 T2 录制的 workflow 轨(src/data/project/agent-t2-workflow.json,2026-09-26;模型 Qwen/Qwen3-8B-MLX-4bit,本机 mlx_lm.server,seed 1,单次调用 max_tokens 8192)。token 逐段数由项目模型自带的 tokenizer 与 chat template 算出,相加与服务端 usage 一致(a00-workflow-agent.json);测试输出与模型回复约 900 字截取(保留头尾),本机路径已脱敏;耗时受本机其他任务影响,只能粗看。

🎯

试三件事:① 在 parse_date 上点「④ 整文件覆写」:为了改 1 行,模型写回了整份 1,302 字符,真实 diff 只有 2 行;② 点「⑤ 重跑测试」:报错换成了 datetime.date(2024, 2, 5) != datetime.date(2024, 3, 5),这就是第二处 bug;③ 切到 slugify 再点「④」:回复里没有 FILE: 标记,兜底规则把 130 字符的片段当成了整份文件,删掉的行里有 import re。

改进:先给流程加一步「看结果」

对第一层,最自然的补法是在流程末尾加一条回边:重跑没过,就把新的报错发回去,让模型再改一次,最多 3 次。对第二层,加一道检查:回复里没有 FILE: 标记就不写回,当作失败重来。

注意这两处改动都还是代码在决定:转不转由测试的退出码决定,写不写回由格式检查决定。模型的输出仍然只是「新文件的内容」,从来不决定下一步做什么。把它放到控制权光谱上看——

控制权光谱 · 下一步由谁决定 代码决定(设计时写好)模型决定(运行时选)运行时才定下次数或去向

六档与代码骨架逐字取自 smolagents 文档《Introduction to agentic systems》(Apache-2.0;2026-09-26 核对),原文写明 agency 是连续的光谱、不是 0 或 1。控制流图是本课把同一个任务画成六档的结构示意,不含任何数字;第 0 档与第 3 档对应本课项目真实录制的两条轨道。

🎯

试两件事:① 勾掉再勾上那条回边:图里的边多了一条、橙色边仍然是 0 条——加了循环,还在第 0 档;② 往右拖一档:模型第一次决定了一条边的去向。第 2 课会一路拖到 Multi-step Agent。

这条带回边的流程,正是 Anthropic 五种 workflow 模式里的 evaluator-optimizer,只是评估者换成了测试本身(第 2 课)。官方的原话是先找最简单的方案、只在需要时加复杂度,并且说很多应用里,把单次模型调用本身做好(加检索、加示例)通常就够了 。本课项目没有录这条带重试的流程在 4 个任务上的表现——它能不能修好 parse_date,现在没有数字;项目线的 M0 可以补录。

极小真数字:这一次调用花在哪

parse_date 那一次调用,服务端报告 prompt 782、completion 2,299 token。用项目模型自带的 tokenizer 和 chat template 逐段数,两边都与服务端一个不差:

parse_date · workflow 唯一一次模型调用的输入(Qwen3-8B 的 tokenizer 与 chat template 逐段计数)
=
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
逐段相加 13 + 64 + 54 + 231 + 420 = 782,与服务端相同(生成脚本 gen_a00_workflow_agent.py 里断言过;四段按「前缀累加」计数,所以相加严格相等)。
同一次调用的输出:为了改一行,它写了多少
=
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
真正改动的只有 1 行(新行 17 token):month, day, year = (int(g) for g in m.groups()) → day, month, year = (int(g) for g in m.groups())。整份重写让这 17 token 的改动占用了 409 token 的代码块;而文件里任何一行被写错、漏写,都会跟着覆写进去——slugify 就是这样丢掉 import re 的。
⚠
常见误解:「workflow 是还没进化好的 agent,迟早要被 agent 取代。」

Anthropic 把两者都归为 agentic systems,只在「路径由预先写好的代码决定」还是「由模型动态决定」上做区分,并且建议先找最简单的方案,原话甚至说这可能意味着根本不建 agentic 系统 。这一课的真实数字也站在这边:workflow 修好的两个任务(word_freq、leap_year)各只用了 4,290 与 2,020 token、一次模型调用。workflow 是一种选择,不是低一级的 agent;它失败的地方要按失败的原因来补,不是一律换成 agent。

🔍 为什么不把测试文件也发过去?为什么只准写回源文件?

这是 workflow.py 里写死的两条规则:发给模型的是所有不在 tests/ 下、文件名不以 test_ 开头的 .py 文件;写回时,路径不在这个清单里的一律拒绝(项目测试 test_writes_outside_the_collected_sources_are_rejected 钉住了这一条)。第二条是为了守住「测试文件不许改」的任务约束——模型如果能改测试,「测试通过」就不再说明任何事。第一条是省事:对这个任务,测试输出里已经有失败断言的原文。规则写死在代码里的好处是可预期、好测;坏处就是 slugify 那种「规则没料到的输入」。

证据三问

问本课的回答
① 它解决什么问题?用最少的机器(一次模型调用 + 几步写死的代码)修好一个已知位置的 bug;可预期、好测、便宜
② 为什么可能有效?当修复需要的信息全在「报错 + 源文件」里时,一次调用就拿到了全部输入
③ 实验上呢?T2 workflow 轨 2 / 4:需要的信息都在 docstring 里的两个任务一次修好;parse_date 缺的是运行时才出现的报错,slugify 败给了程序自己的写回规则

演进地图:你在这里

什么时候需要 agent:每一代都被上一代的某个真数字逼出来
  1. 朴素 单次调用:报错 + 源文件发给模型,要回整份新文件,覆写后重跑 本课项目 workflow.py · 5 步全由代码决定 你在这里
    解决了只调一次模型,每一步都写在代码里:可预期、好测、便宜。
    卡在T2 workflow 轨 2 / 4 通过。parse_date 只改了报错指到的那一行,重跑才露出第二处(datetime.date(2024, 2, 5) != datetime.date(2024, 3, 5));slugify 的回复只有函数片段,兜底规则把 130 字符的片段当整份文件写回(原文件 365 字符),丢了 import re。
  2. 第一代 加步骤、加分支:五种 workflow 模式 prompt chaining · routing · parallelization · orchestrator-workers · evaluator-optimizer
    解决了每一类失败补一条预先写好的路径:测试没过就带着新报错再来一次(评估者是测试的退出码)、回复格式不对就拒绝写回。
    卡在分支要在设计时写出来,可真实路径是在运行时长出来的:T2 agent 轨 4 个任务走出 4 条不同的工具序列;同一配置重跑 4 次 parse_date(seed 1 / 2 / 3 / 1),走出 4 条。
  3. 第二代 把「下一步做什么」交给模型:while + 工具 = agent 本课项目 loop.py(A1 细讲)
    解决了边在运行时长出来:T2 agent 轨 3 / 4 通过,包括 workflow 没修好的 parse_date 与 slugify。
    卡在代价:workflow 已经能解决的任务上 token word_freq ×5.3、leap_year ×4.5,其中 word_freq 的 agent 反而没修好;同配置、同 seed 重跑,2 / 4 个任务在中途分叉。
  4. 第三代 先判别再选轨道:能用一段代码就不用 agent 三条判别标准 · 先直接调 API
    解决了录制前写下三条标准并逐个预测,录制后对照:预测对 3 / 4。
    卡在预测错的 slugify 不是任务本身需要 agent,而是 workflow 自己的写回规则出了错(回复没有 FILE: 标记时的兜底)。每条轨道每个任务只跑 1 次:只能当例子,不能当比率。
  5. 岔路 混合:把固定编排写成 agent 的一个工具 learn-claude-code s16 Workflow Runtime
    解决了编排形状固定的部分(并行审查、逐条验证、断点续跑)写进脚本;模型只决定「要不要调这个 workflow、传什么参数」。
    卡在同一个仓库里两种说法并存:README :11 与 :41 说 agency 来自训练、不是编排;:239 说编排形状固定就写进代码。界线画在哪,没有实验回答。
  6. 岔路 通用 agent 切换到编码姿态 Hermes coding_context.py
    解决了同一个循环,识别出代码工作区后注入编码说明(约 577 token,Qwen3 tokenizer 计)与工作区快照;focus 模式才会收窄工具集与技能索引。
    卡在判定靠文件标记:本课任务仓单独拷出来(README.md、dateparse.py、test_dateparse.py,没有 .git)在默认 auto 下被判成 general;放在项目目录里原地跑,却因为往上 3 层有个 requirements.txt 被判成 coding。姿态在会话开始解析一次,中途不变。
数字口径:本课项目 T2 双轨录制(Qwen3-8B-MLX-4bit,本机 mlx_lm.server,seed 1,2026-09-26;4 个任务各跑 1 次,只说明现象会发生,不估计比率);孪生运行 = A1 里与 T2 agent 轨配置相同、seed 相同的那次运行;Hermes 一行是 pin 56dc01d9 的原实现在临时目录里实跑的判定。

一句话

📌
单次调用只能用你喂给它的信息,修 bug 需要的信息往往要边做边找。
🚦

先停在这里:记住两种失败——缺的是「运行时才出现的信息」,还是「程序自己的规则走错了」;它们的补法不同。
再深一层:「为了改一行而重写整份文件」这件事,在 A2 的第 1 课(按行号改,行不行?)和第 2 课(报错还是容错)里有完整的来龙去脉。

下一个缺口

那就多加几步、多加几个分支? → 第 2 课:多加几步、加分支,能覆盖所有情况吗?

上面那条「没过就再来」的回边补上了第一层。可每遇到一种新失败就加一条路,路要画到什么时候?下一课把 agent 轨真实走出来的路径摆出来,看看要预先画多少条。

你可能会问