一、为什么不用框架,要手写一遍
先说清楚:手写不是为了省依赖,是当对照组。
LangGraph 这类框架把循环封起来了,也把"出错时到底哪一环坏了"一起封起来了。结果不对的时候,你面对的是三种长得一模一样的现场:
- 模型压根没调工具(直接抢答)?
- 想调工具但参数写错了?
- 工具返回的东西它没看懂?
在框架里这三种情况都表现为"跑完了,结果不对",然后你开始猜。手写一遍,每一步的原始输出、解析判定、工具返回都在 trace 里,才有可能把失败归因清楚。
但对照组有个前提:两边必须用同一批工具函数。如果手写版和框架版调的不是同一份 web_search,测出来的差异就分不清是编排层的还是工具实现的。所以我的 TOOLS 只是一张「名字 → 函数」的表,工具是普通 Python 函数,不是 langchain 的 Tool 对象——两条链路直接调同一批函数,差异才全部落在编排这一层。
另一个诚实的交代:上一篇结尾我预告的是"150 行拆开工具调用循环"。实际写完 383 行。多出来的不是循环本身——while steps < max_steps 那个循环只有零头——是失败分账的部分:解析失败按类型分开计数、观察按工具裁剪、每步原始输出进 trace。做完才发现,"让循环跑起来"和"让失败说得清楚"是两件工作量差一个量级的事。
二、实验怎么设计
任务集 3 组 × 10 条。**分组的目的只有一个:让失败可归因。**笼统的"失败率 15.9%"解释不了任何事,分组才能回答"失败集中在哪类任务上":
| 组 | 任务类型 | 用到的工具 | 参数形态 |
|---|---|---|---|
| G1 | 企业信息检索 | `web_search` | 单参数 |
| G2 | 简历证据检索 | `search_resume` | 单参数 |
| G3 | 算匹配分 | `calc_match_score` | **嵌套结构**(`skills` 是对象数组) |
配置:glm-4-flash(temperature=0.0),max_steps=6,每组 10 条任务连跑两轮。
统计口径先钉死,这两个定义后面所有数字都建立在上面:
- 模型格式失败率 = 解析失败的步数 ÷ 总步数。分母是步数不是任务数——一步失败通常会重试一步,按任务数算会把失败摊薄,看着好看但没有意义。
- 解析器丢失率 = 模型输出完全合规、但解析器截错的步数 ÷ 总步数。这一项是"解析器的锅",和上一项严格互斥,不能相加。
还有一个要提前交代的边界:这 30 条任务偏简单(都是单跳问答),是为量格式失败率设计的,不是为量答案质量。这个边界到第五节会变得很重要。
三、数据:两轮 30 任务,逐任务一致
| 观测项 | 第 1 轮 | 第 2 轮 | 结论 |
|---|---|---|---|
| 收敛(拿到 Final Answer) | 30/30 | 30/30 | ✅ |
| **总步数** | **63** | **63** | ✅ 完全一致 |
| 平均步数 / 任务 | 2.10 | 2.10 | ✅ |
| **模型格式失败率** | **0/63 = 0.0%** | **0/63 = 0.0%** | ✅ |
| **解析器丢失率** | **10/63 = 15.9%** | **10/63 = 15.9%** | ✅ |
| 未调用任何工具就作答 | 0 条 | 0 条 | ✅ |
| 工具调用失败(选错 / 参数错 / 报错) | 0 | 0 | ✅ |
| 平均耗时 / 任务 | 11.0 s | 11.1 s | ❌ 波动 |
| prompt tokens | 54893 | 54900 | ❌ 微漂 |
"逐任务一致"比汇总一致更有说服力:两轮里每一条任务的步数、调用的工具、解析器丢失步数完全相同——3 条任务跑了 3 步,其余 27 条各 2 步;解析器丢失全部落在 G3 那 10 条上,每条恰好丢 1 步。漂的只有耗时、token 和答案文本。
这个模式和我在第一个项目里观察到的一样:结构化指标稳定,自由文本和耗时必漂。写博客能引用的数字,只有前一类。
四、核心发现:预判的坑一个没踩,踩的是我的解析器
按组拆开,事情就清楚了:
| 组 | 总步数 | 模型格式失败 | 解析器丢失 | 丢失率 |
|---|---|---|---|---|
| G1(单参数检索) | 21 | 0 | 0 | **0.0%** |
| G2(单参数检索) | 22 | 0 | 0 | **0.0%** |
| G3(嵌套参数) | 20 | 0 | **10** | **50.0%** |
| 合计 | 63 | 0 | 10 | 15.9% |
**模型在 63 步里一次都没有违反格式。**丢掉的 10 步全部是解析器截错 JSON,而且全部集中在参数是嵌套结构的 G3——单参数组(G1/G2)是零。
机制:正则数不了括号
我最初用的取 JSON 写法,大概是很多人都会写的:
m = re.search(<span>r"\{.*?\}"</span>, text) <span># 非贪婪,取第一个 {...}</span>
非贪婪匹配会在第一个 } 就收尾。G3 的参数长这样:
<span>{</span><span>"skills"</span><span>:</span> <span>[</span><span>{</span><span>"skill"</span><span>:</span> <span>"Python"</span><span>,</span> <span>"weight"</span><span>:</span> <span>3.0</span><span>,</span> <span>"hit"</span><span>:</span> <span><span>true</span></span><span>}</span><span>,</span> ...<span>]</span><span>,</span> <span>"has_relevant_project"</span><span>:</span> <span><span>true</span></span><span>}</span>
第一个 } 属于内层对象,截出来是半截 JSON,json.loads 直接失败。还有一条更隐蔽的同类问题:{"query": "括号 } 出现在字符串里"}——字符串字面量里的 } 也会让正则提前收尾。
关键是:这两类情况里模型的输出完全合规。而截错之后报出来的错是"JSON 不合法"——看起来像模型的锅。
这是我真实实验里 G3 第 1 条的原始输出(两轮逐字相同):
[<span>step</span> <span>1</span>] 解析=action naive_ok=<span>False</span>
Action: calc_match_score
Action Input: {<span>"skills"</span>: [{<span>"skill"</span>: <span>"Python"</span>, <span>"weight"</span>: <span>3.0</span>, <span>"hit"</span>: <span>true</span>},
{<span>"skill"</span>: <span>"RAG"</span>, <span>"weight"</span>: <span>3.0</span>, <span>"hit"</span>: <span>true</span>},
{<span>"skill"</span>: <span>"Docker"</span>, <span>"weight"</span>: <span>1.0</span>, <span>"hit"</span>: <span>false</span>}],
<span>"has_relevant_project"</span>: <span>true</span>}
一个多余字符都没有,naive_ok 却是 False——非贪婪正则解析不了它。
修法不稀奇,分账才值钱
正确的取法是扫一遍、维护深度、跳过字符串字面量:
<span>def</span> <span>_balanced_json</span>(<span>text: <span>str</span></span>) -> <span>str</span> | <span>None</span>:
start = text.find(<span>"{"</span>)
<span>if</span> start == -<span>1</span>:
<span>return</span> <span>None</span>
depth, in_str, escaped = <span>0</span>, <span>False</span>, <span>False</span>
<span>for</span> i <span>in</span> <span>range</span>(start, <span>len</span>(text)):
ch = text[i]
<span>if</span> in_str:
<span>if</span> escaped:
escaped = <span>False</span>
<span>elif</span> ch == <span>"\\"</span>:
escaped = <span>True</span>
<span>elif</span> ch == <span>'"'</span>:
in_str = <span>False</span>
<span>elif</span> ch == <span>'"'</span>:
in_str = <span>True</span>
<span>elif</span> ch == <span>"{"</span>:
depth += <span>1</span>
<span>elif</span> ch == <span>"}"</span>:
depth -= <span>1</span>
<span>if</span> depth == <span>0</span>:
<span>return</span> text[start : i + <span>1</span>]
<span>return</span> <span>None</span> <span># 括号没配平,按 bad_json 处理</span>
这段代码本身没有任何稀奇之处。我认为值钱的是让它和那个会错的正则同时跑、分开计数:
parsed = _balanced_json(raw) <span># 主路径:括号配平</span>
naive = _NAIVE_JSON_RE.search(raw) <span># 对照:非贪婪正则(就是容易写错的那种)</span>
<span># parsed 成功而 naive 失败 → 这一步记"解析器丢失",不记"模型格式失败"</span>
parse_step 返回里带一个 naive_ok 字段,专门记"这一步如果交给非贪婪正则能不能过"。这样"模型的锅"和"解析器的锅"是两本账。配套还写了 12 条离线单测——不联网、不调模型,嵌套截断和字符串截断各占几条,跑一下就复现。
如果没做这个分账,我的实验结论会写成:"glm-4-flash 格式失败率 15.9%"。方向完全相反——不是模型不行,是我预写的解析器不行。归因错了,后面的动作就全错:你会去改 prompt、换模型、堆 few-shot,而真正该改的是十几行解析代码。
五、第二个发现:跑得顺不等于答得对
上面所有数字都在说"这套循环跑得很稳"。但 30 条收敛、0 格式失败,不代表答案都对。逐条读答案之后,发现 1 条答漏了:
| 任务 | 问题 | 模型答案 | 实际情况 |
|---|---|---|---|
| G2-02 | 我的简历里提到过哪些数据库? | "提到了数据库原理这门课程" | 简历「专业技能」块里还写着"了解**向量数据库(Chroma)**",漏了 |
trace 里能看到完整过程,三步走的全是"合理"的路:
- 第 1 步用
数据库检索简历 → 召回的 top-3 是「教育背景」「项目经历」,「专业技能」那块压根没进来(embedding 召回的局限,不是模型的错); - 第 2 步模型把上一步观察里出现的词拿来当新 query(
数据库原理)→ 越查越窄,还是没捞到「专业技能」那块; - 第 3 步直接
Final Answer收工。
每一步单看都没毛病,合起来是一个漏答案。手写 ReAct 的短板在这里暴露得很准:循环里没有任何机制判断"检索到的东西够不够回答这个问题"。模型拿到什么就答什么,答完就收工。
这恰好是"要不要上框架"最实在的论据。框架的价值不在于省掉那个 while 循环——循环谁都会写——而在于能让"证据够不够"变成一个可插拔的校验节点,在收工之前拦一道。手写版里这个判断散落在模型的自觉里,而模型的自觉靠不住。
必须划清的边界:30 条里只有这 1 条暴露问题,比例太小,不能写成"答案错误率 3.3%"——这批任务本来就是为量格式失败率设计的单跳问答,对答案质量没有区分度。这条只能算定性发现,答案质量要等专门的评测集(多跳、含负样本)来量。
六、顺带踩的坑:Observation 是上下文杀手
还有一个不跑批量实验根本发现不了的坑:观察值原样塞回上下文,循环活不过第 3 步。
一次 web_search 返回 5 条、每条正文约 690 字,一条观察就是 3500 字——比问题本身大两个数量级。第 3 步上下文就顶满了,后面全是在稀释。
解法是按工具裁剪观察:web_search 只留标题、来源和正文前 160 字;calc_match_score 只留分数和算式。这不是优化,是能不能跑完的前提——裁剪规则的原则是"留下模型判断下一步所需的最小信息"。
七、这个实验没回答的问题
按惯例,最后是诚实清单:
- 答案质量没有量化。第五节只有 1 条定性发现,要等 30 条评测集(多跳 + 负样本)才有分母。
- prompt 是刻意朴素的:只讲格式、不给 few-shot 示例。所以这里的 0% 格式失败反映的是机制本身,不是 prompt 工程的上限——堆示例当然可以更低,但那测的就是调参水平了。
- 两轮 × 30 条,样本仍然小。逐任务一致是很强的信号,但"稳定"要更大样本才能钉死。
- 没测多轮工具链。任务最多用到 3 步,"第 1 步的结果影响第 4 步的工具选择"这类场景没覆盖。
八、下一步
- 30 条评测集(多跳问答 + 负样本),把答案质量从定性变定量——这也是整个项目可复现性补齐的最后一块。
- function calling 版对照组:同一批工具函数,换原生 function calling 接入,和手写 ReAct、LangGraph 三方对比。解析器丢失率这一项在 function calling 下理论上不存在,正好用来验证"这 15.9% 是不是格式化接入的固有成本"。
- 回主线,写完写作 Agent,把四节点链路收口。
项目仓库:gitee.com/epitome-of-the-sky/jobfit-agent(手写 ReAct 在 app/react_manual.py,批量实验在 eval/,离线单测 python -m eval.test_parse_step 不联网可跑)
系列文章:
- 第 1 篇:毕设复盘 —— RAG 检索链路消融实验
- 第 2 篇:手写 ReAct 30 任务实测(本篇)
价值在于把失败归因拆成两本可复现的账:解析器丢失率与模型格式失败率。适合做 Agent 编排、框架选型或写评测脚本的人,尤其是被 JSON 截断坑过的开发者。