一聚教程网:一个值得你收藏的教程网站

最新下载

热门教程

手写 ReAct 实测 30 个任务:模型零格式错误,问题出在解析器

时间:2026-09-16 13:12:01 编辑:袖梨 来源:一聚教程网

在智能体任务中,报出“JSON 不合法”并不意味着模型一定生成了错误格式。为了确认 ReAct 循环究竟在哪一环失效,我用同一组工具函数手写编排逻辑,并对任务进行重复测试,分别记录模型输出、解析结果和工具返回。结果显示,真正需要修复的不是提示词或模型,而是无法处理嵌套结构的解析代码。

接上一篇。第二个项目(LangGraph 多智能体岗位匹配)做到一半,我干了件框架时代的"逆行"事:不用 create_react_agent,不用任何框架,手写了一遍 ReAct 循环,然后在 30 个任务上跑了批量实验,同一输入连跑两轮。

跑之前我预判了三个坑:模型忘了输出 Observation、JSON 参数缺引号、选了不存在的工具。这三个一个都没出现——63 步里 glm-4-flash 一次都没违反格式,33 次工具调用全部成功。

真正"丢"掉的 10 步,全是我自己写的那个 JSON 解析器干的。而且如果不做对照计数,这口锅会被顺理成章地扣在模型头上——结论就完全反了。这篇写实验怎么设计、数据说了什么,以及我为什么庆幸自己把"谁失败"这件事拆开来记了。

一、为什么不用框架,要手写一遍

先说清楚:手写不是为了省依赖,是当对照组

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/3030/30
总步数6363✅ 完全一致
平均步数 / 任务2.102.10
模型格式失败率0/63 = 0.0%0/63 = 0.0%
解析器丢失率10/63 = 15.9%10/63 = 15.9%
未调用任何工具就作答0 条0 条
工具调用失败(选错 / 参数错 / 报错)00
平均耗时 / 任务11.0 s11.1 s❌ 波动
prompt tokens5489354900❌ 微漂

"逐任务一致"比汇总一致更有说服力:两轮里每一条任务的步数、调用的工具、解析器丢失步数完全相同——3 条任务跑了 3 步,其余 27 条各 2 步;解析器丢失全部落在 G3 那 10 条上,每条恰好丢 1 步。漂的只有耗时、token 和答案文本。

这个模式和我在第一个项目里观察到的一样:结构化指标稳定,自由文本和耗时必漂。写博客能引用的数字,只有前一类。

四、核心发现:预判的坑一个没踩,踩的是我的解析器

按组拆开,事情就清楚了:

总步数模型格式失败解析器丢失丢失率
G1(单参数检索)21000.0%
G2(单参数检索)22000.0%
G3(嵌套参数)2001050.0%
合计6301015.9%

**模型在 63 步里一次都没有违反格式。**丢掉的 10 步全部是解析器截错 JSON,而且全部集中在参数是嵌套结构的 G3——单参数组(G1/G2)是零。

机制:正则数不了括号

我最初用的取 JSON 写法,大概是很多人都会写的:

m = re.search(r"{.*?}", text)   # 非贪婪,取第一个 {...}

非贪婪匹配会在第一个 } 就收尾。G3 的参数长这样:

{"skills": [{"skill": "Python", "weight": 3.0, "hit": true}, ...], "has_relevant_project": true}

第一个 } 属于内层对象,截出来是半截 JSON,json.loads 直接失败。还有一条更隐蔽的同类问题:{"query": "括号 } 出现在字符串里"}——字符串字面量里的 } 也会让正则提前收尾

关键是:这两类情况里模型的输出完全合规。而截错之后报出来的错是"JSON 不合法"——看起来像模型的锅。

这是我真实实验里 G3 第 1 条的原始输出(两轮逐字相同):

[step 1] 解析=action naive_ok=False
  Action: calc_match_score
  Action Input: {"skills": [{"skill": "Python", "weight": 3.0, "hit": true},
                {"skill": "RAG", "weight": 3.0, "hit": true},
                {"skill": "Docker", "weight": 1.0, "hit": false}],
                "has_relevant_project": true}

一个多余字符都没有,naive_ok 却是 False——非贪婪正则解析不了它。

修法不稀奇,分账才值钱

正确的取法是扫一遍、维护深度、跳过字符串字面量:

def _balanced_json(text: str) -> str | None:
    start = text.find("{")
    if start == -1:
        return None
    depth, in_str, escaped = 0, False, False
    for i in range(start, len(text)):
        ch = text[i]
        if in_str:
            if escaped:
                escaped = False
            elif ch == "\":
                escaped = True
            elif ch == '"':
                in_str = False
        elif ch == '"':
            in_str = True
        elif ch == "{":
            depth += 1
        elif ch == "}":
            depth -= 1
            if depth == 0:
                return text[start : i + 1]
    return None   # 括号没配平,按 bad_json 处理

这段代码本身没有任何稀奇之处。我认为值钱的是让它和那个会错的正则同时跑、分开计数

parsed = _balanced_json(raw)           # 主路径:括号配平
naive  = _NAIVE_JSON_RE.search(raw)    # 对照:非贪婪正则(就是容易写错的那种)

# parsed 成功而 naive 失败 → 这一步记"解析器丢失",不记"模型格式失败"

parse_step 返回里带一个 naive_ok 字段,专门记"这一步如果交给非贪婪正则能不能过"。这样"模型的锅"和"解析器的锅"是两本账。配套还写了 12 条离线单测——不联网、不调模型,嵌套截断和字符串截断各占几条,跑一下就复现。

如果没做这个分账,我的实验结论会写成:"glm-4-flash 格式失败率 15.9%"。方向完全相反——不是模型不行,是我预写的解析器不行。归因错了,后面的动作就全错:你会去改 prompt、换模型、堆 few-shot,而真正该改的是十几行解析代码。

五、第二个发现:跑得顺不等于答得对

上面所有数字都在说"这套循环跑得很稳"。但 30 条收敛、0 格式失败,不代表答案都对。逐条读答案之后,发现 1 条答漏了:

任务问题模型答案实际情况
G2-02我的简历里提到过哪些数据库?"提到了数据库原理这门课程"简历「专业技能」块里还写着"了解向量数据库(Chroma)",漏了

trace 里能看到完整过程,三步走的全是"合理"的路:

  1. 第 1 步用 数据库 检索简历 → 召回的 top-3 是「教育背景」「项目经历」,「专业技能」那块压根没进来(embedding 召回的局限,不是模型的错);
  2. 第 2 步模型把上一步观察里出现的词拿来当新 query(数据库原理)→ 越查越窄,还是没捞到「专业技能」那块;
  3. 第 3 步直接 Final Answer 收工。

每一步单看都没毛病,合起来是一个漏答案。手写 ReAct 的短板在这里暴露得很准:循环里没有任何机制判断"检索到的东西够不够回答这个问题"。模型拿到什么就答什么,答完就收工。

这恰好是"要不要上框架"最实在的论据。框架的价值不在于省掉那个 while 循环——循环谁都会写——而在于能让"证据够不够"变成一个可插拔的校验节点,在收工之前拦一道。手写版里这个判断散落在模型的自觉里,而模型的自觉靠不住。

必须划清的边界:30 条里只有这 1 条暴露问题,比例太小,不能写成"答案错误率 3.3%"——这批任务本来就是为量格式失败率设计的单跳问答,对答案质量没有区分度。这条只能算定性发现,答案质量要等专门的评测集(多跳、含负样本)来量。

六、顺带踩的坑:Observation 是上下文杀手

还有一个不跑批量实验根本发现不了的坑:观察值原样塞回上下文,循环活不过第 3 步。

一次 web_search 返回 5 条、每条正文约 690 字,一条观察就是 3500 字——比问题本身大两个数量级。第 3 步上下文就顶满了,后面全是在稀释。

解法是按工具裁剪观察:web_search 只留标题、来源和正文前 160 字;calc_match_score 只留分数和算式。这不是优化,是能不能跑完的前提——裁剪规则的原则是"留下模型判断下一步所需的最小信息"。

七、这个实验没回答的问题

按惯例,最后是诚实清单:

  1. 答案质量没有量化。第五节只有 1 条定性发现,要等 30 条评测集(多跳 + 负样本)才有分母。
  2. prompt 是刻意朴素的:只讲格式、不给 few-shot 示例。所以这里的 0% 格式失败反映的是机制本身,不是 prompt 工程的上限——堆示例当然可以更低,但那测的就是调参水平了。
  3. 两轮 × 30 条,样本仍然小。逐任务一致是很强的信号,但"稳定"要更大样本才能钉死。
  4. 没测多轮工具链。任务最多用到 3 步,"第 1 步的结果影响第 4 步的工具选择"这类场景没覆盖。

八、下一步

  1. 30 条评测集(多跳问答 + 负样本),把答案质量从定性变定量——这也是整个项目可复现性补齐的最后一块。
  2. function calling 版对照组:同一批工具函数,换原生 function calling 接入,和手写 ReAct、LangGraph 三方对比。解析器丢失率这一项在 function calling 下理论上不存在,正好用来验证"这 15.9% 是不是格式化接入的固有成本"。
  3. 回主线,写完写作 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 任务实测(本篇)

热门栏目