跳到主要内容

自我纠错:回滚、去重与对模型输出的容错修复

30 秒导读: 开源模型(30B/72B)没有闭源大模型那么"听话"——它会把工具调用格式写错、会拒答、会反复搜同一个词、会吐出坏掉的 JSON。MiroThinker 能让这类模型稳定跑几百个 turn,靠的不是模型本身,而是编排层围着"模型会犯的错"做的一整套容错:检测到问题就回滚(撤掉这一轮、重来),能就地修的就地修(参数别名、server 名、坏 JSON)。这一章讲的就是这层"纠错网"。

本章聚焦错误的判定与修复这一件事。循环骨架(主 Agent / 子 Agent 怎么一轮轮转)在 02-orchestrator-loop.md 里讲;这里只讲:每一轮里,系统怎么发现"这轮出错了"、发现后怎么回滚、以及在错误发生前后怎么把模型的输出"修好"。


1. 这是什么(零基础也能懂)

先建立一个直觉

让模型自己开着工具跑几百步,是件很脆弱的事。想象你雇了一个很努力但有点粗心的助理:

  • 他有时会把"填表的格式"写错(该用 A 格式却用了 B 格式);
  • 有时会说"这个我做不了"就撂挑子;
  • 有时会一遍遍去查同一个已经查过的东西;
  • 有时递给你的材料是残缺的(搜出来一条结果都没有,或者工具报错了)。

如果每一步都照单全收,几百步下来错误会像滚雪球一样累积,最后整个任务废掉。

MiroThinker 的做法:给助理配一个"复核 + 撤销"机制

MiroThinker 的编排器在每一轮模型说完话之后,先不急着执行,而是拿几把"尺子"去量这轮输出:

  • 格式对不对?
  • 是不是拒答了?
  • 这个查询是不是刚才查过?
  • 工具执行完,结果是不是"废的"(报错 / 空)?

只要有一把尺子亮红灯,就执行一个统一的动作:回滚(rollback)——把刚加进对话历史的那条助理消息撤掉,turn_count 减一,当作这一轮"没发生过",让模型重来一次

三个层次的容错

这套纠错网分三层,由外到内:

层次干什么触发/动作
回滚发现这轮"废了"就撤销重来格式错 / 拒答 / 重复查询 / 结果报错或空 → pop() + 重试
就地修复模型输出"差一点点",直接改对而不重来参数别名改名、补 sandbox_id、从 system prompt 反解 server 名
解析容错把模型吐的坏 JSON / 三种格式统一解析出来json_repair、多种 API 格式兼容、过滤 None

一句话:回滚是"重来",修复是"改对",解析是"读懂"。它们叠在一起,才让粗心的开源模型也能跑长任务。


2. 顶层全景(纠错网怎么转)

一轮里,纠错发生在哪几个点

下面这张图是"读这张图的钥匙":从上到下是一轮(turn)的时间顺序, 是一个检测点,命中任一检测点都走同一个出口——回滚

模型输出这一轮的文本 / tool_calls


┌─────────────────────┐
│ 解析层(读懂它) │ parse_llm_response_for_tool_calls
│ ·三种格式兼容 │ safe_json_loads + json_repair
│ ·坏 JSON 抢救 │ filter_none_values
└──────────┬──────────┘

┌─────────────────────┐
│ 修复层(改对它) │ fix_server_name_in_text(server 名)
│ ·server/tool 名 │ fix_tool_call_arguments(参数别名)
│ ·参数别名/补默认值 │ (发生在解析前后,不回滚)
└──────────┬──────────┘

没有 tool_calls?──是──► ◆ 检测:文本里混着 MCP 标签? → 回滚
│ ◆ 检测:命中拒答关键词? → 回滚
│ 都不是 → 正常收尾(没工具要调)
▼ 有 tool_calls
◆ 检测:重复查询?(same query) ──► 回滚
│ 否

执行工具调用


◆ 检测:结果 = Unknown tool / Error / 空搜索 ──► 回滚
│ 否

成功 → consecutive_rollbacks 归零

回滚这个出口不是无限的:它被一个连续回滚上限(MAX_CONSECUTIVE_ROLLBACKS,默认 5)保护着。连续回滚太多次就"放行"或"结束",防止在同一个坎上死循环。

部件一句话职责

部件干什么文件
_handle_response_format_issues检测格式错 / 拒答,决定回滚还是结束core/orchestrator.py
_check_duplicate_query / _record_querysame query 去重,重复就回滚core/orchestrator.py
should_rollback_result判定工具结果是否"废"(Unknown/Error/空)core/tool_executor.py
fix_tool_call_arguments修参数别名、补 sandbox_idcore/tool_executor.py
fix_server_name_in_text + 映射解析修 server/tool 名(从 system prompt 反解)utils/parsing_utils.py
parse_llm_response_for_tool_calls / safe_json_loads三格式解析 + 坏 JSON 抢救utils/parsing_utils.py
mcp_tags / refusal_keywords检测用的关键词表utils/prompt_utils.py

3. 回滚机制(核心)

这节讲第一层:发现这轮废了,就撤销重来。先看三类触发,再看它们共用的那套"回滚套路"。

3.1 回滚的共同套路

不管哪种触发,回滚都是同一组动作。理解了这四步,后面三类触发就都是"什么时候执行这四步"的问题。

# 示意,非源码:一次回滚做的四件事
turn_count -= 1 # 这一轮不算数
consecutive_rollbacks += 1 # 连续回滚计数 +1
if message_history[-1]["role"] == "assistant":
message_history.pop() # 把刚加进去的助理消息撤掉
# 然后 continue —— 让模型基于"没出错的历史"重新说一次

重点看两个数字:

  • turn_count -= 1 抵消了循环开头的 turn_count += 1,所以回滚不消耗轮次预算——模型可以在同一"轮号"上多次尝试。
  • consecutive_rollbacks连续回滚数;它有上限保护(下面 3.5),而且一旦成功执行就归零(见 3.6)。

真实源码里,主 Agent 的重复查询回滚长这样(core/orchestrator.py:296-307,函数 _check_duplicate_query):

if consecutive_rollbacks < self.MAX_CONSECUTIVE_ROLLBACKS - 1:
message_history.pop()
turn_count -= 1
consecutive_rollbacks += 1
...
return True, True, turn_count, consecutive_rollbacks, message_history

三类触发都复用这四步,只是触发条件pop 的守卫略有差别。

3.2 触发一:格式错 / 拒答

第一类触发在 _handle_response_format_issues(core/orchestrator.py:180-255),它只在模型这轮没产生任何合法 tool_calls 时才被调用(见 orchestrator.py:872-887)。它量两把尺子:

尺子 A — 格式错(MCP 标签泄漏): 如果模型本该以规范方式调工具,结果却把 <use_mcp_tool><server_name> 这类原始标签当普通文本吐了出来(说明它格式写错了、没被解析成 tool_call),就判为格式错。检测用的关键词表是 mcp_tags(utils/prompt_utils.py:69-76):

mcp_tags = [
"<use_mcp_tool>", "</use_mcp_tool>",
"<server_name>", "</server_name>",
"<arguments>", "</arguments>",
]

判定就是一句 any(...)(orchestrator.py:206):

if any(mcp_tag in assistant_response_text for mcp_tag in mcp_tags):

尺子 B — 拒答: 如果模型输出里出现"我做不了"这类话,判为拒答。关键词表 refusal_keywords(utils/prompt_utils.py:78-82)只列了三条高频拒答语:

refusal_keywords = [
"time constraint",
"I’m sorry, but I can’t",
"I'm sorry, I cannot solve",
]

命中任一把尺子 → 执行 3.1 的回滚套路,让模型重说一次。这里 pop() 前多了个守卫 if message_history[-1]["role"] == "assistant"(orchestrator.py:210),只撤助理消息,避免误删用户/工具消息。

3.3 触发二:重复查询去重

第二类触发防的是模型"原地打转"——一遍遍搜同一个词。逻辑在 _check_duplicate_query(orchestrator.py:257-316)。

怎么定义"同一个查询":get_query_str_from_tool_call(tool_executor.py:102-134)按工具类型拼出一个查询指纹,例如:

# 示意,非源码:不同工具取不同参数当"查询"
elif tool_name == "google_search":
return "google_search_" + arguments.get("q", "")
elif tool_name == "scrape_and_extract_info":
return f"scrape_and_extract_info_{url}_{info_to_extract}"

怎么记账: 用一个嵌套字典 used_queries[cache_name][query_str] 计数(orchestrator.py:292-293)。cache_name 一般形如 main_google_search,把不同 agent、不同工具的查询分桶。

判定与动作: 如果这个指纹的计数 count > 0(之前查过),就回滚(orchestrator.py:295-307);否则放行,由 _record_query(orchestrator.py:318-325)把计数 +1 记下来。注意:去重的记账只在真正执行前记——回滚掉的那次不会污染计数。

3.4 触发三:工具结果"废了"

前两类在执行工具之前拦截;第三类在执行工具之后复核——结果是不是"废的"?判定收在 should_rollback_result(tool_executor.py:240-258):

return (
str(result).startswith("Unknown tool:")
or str(result).startswith("Error executing tool")
or self.is_google_search_empty_result(tool_name, tool_result)
)

三种"废":

情况含义判定处
Unknown tool:模型调了个不存在的工具前缀匹配
Error executing tool工具执行报错前缀匹配
google_search 空结果搜了但 organic 一条都没有(查询太烂)is_google_search_empty_result

第三种最有意思:is_google_search_empty_result(tool_executor.py:162-191)把结果解析成 dict,看 organic 列表长度,len(organic) == 0 就判空。空搜索本身不是"报错",但对深度研究 agent 来说等于白跑一轮——所以也回滚,逼模型换个查询词重来。

命中任一条 → 在主循环里就地执行回滚四步(orchestrator.py:1019-1036),break 出当轮工具循环。

3.5 上限保护:别在同一个坎上死循环

三类触发都套着同一层护栏 MAX_CONSECUTIVE_ROLLBACKS(默认常量 DEFAULT_MAX_CONSECUTIVE_ROLLBACKS = 5,orchestrator.py:50)。它有两道防线:

防线一(循环入口): 每轮开头先查(orchestrator.py:816-820):

if consecutive_rollbacks >= self.MAX_CONSECUTIVE_ROLLBACKS:
# 直接 break —— 不再尝试

防线二(回滚判定处): 每个触发点回滚前都要 consecutive_rollbacks < self.MAX_CONSECUTIVE_ROLLBACKS - 1 才回滚;否则走 else 分支——不同触发在这里态度不同:

  • 格式错 / 拒答:达到上限就结束 agent 循环(return ..., should_break=True,orchestrator.py:220-226);
  • 重复查询:达到上限就放行这个重复查询(Allow Duplicate,orchestrator.py:308-314)——毕竟"再查一次"总比"卡死"强。

这个差别是刻意的:格式错/拒答连翻 5 次说明模型真的懵了,该收场;而重复查询顶多是低效,放行让它继续跑更划算。

3.6 成功即归零:护栏只针对"连续"失败

关键的一笔:consecutive_rollbacks连续计数,一旦某轮成功执行到底就清零(orchestrator.py:1096-1102):

if consecutive_rollbacks > 0:
# 记一条 "Recovery" 日志
...
consecutive_rollbacks = 0

所以护栏防的是"卡在同一个坎上",而不是"整个任务累计回滚太多"。一个健康的长任务可以回滚几十次——只要每次都能恢复,就不会撞上限。这正是它能跑几百 turn 的原因之一。


4. 对模型输出的修复(改对,不重来)

回滚是"重来",代价是浪费一轮。有些错误根本不用重来——模型只是把参数名写错、把 server 名填错,直接改对就行。这是第二层:就地修复

4.1 参数别名与补默认值

fix_tool_call_arguments(tool_executor.py:68-100)在执行前把模型常写错的参数名"翻译"成对的名字:

工具模型爱写错的改成附加动作
scrape_and_extract_infodescription / introductioninfo_to_extract——
run_python_codecodecode_blocksandbox_id 时补 "default"

真实实现(tool_executor.py:92-98):

if tool_name == "run_python_code":
if "code_block" not in fixed_args and "code" in fixed_args:
fixed_args["code_block"] = fixed_args.pop("code")
if "sandbox_id" not in fixed_args:
fixed_args["sandbox_id"] = "default"

sandbox_id="default" 这一手很实用:模型经常忘了带沙箱 id,补个默认值就能触发无状态回退,而不是直接报错回滚。

4.2 从 system prompt 反解 server 映射

MCP 协议里,调工具要同时给对 server_nametool_name。模型经常把这俩配错——尤其是几个高频工具。MiroThinker 的修法很聪明:从 system prompt 里反解出正确映射,再去改模型的输出

第一步,建映射: parse_tool_server_mapping(parsing_utils.py:24-55)扫 system prompt 里的这种结构:

## Server name: tool-python
### Tool name: run_python_code

但它只关心 3 个模型最爱填错的工具(parsing_utils.py:42):

TARGET_TOOLS = {"run_python_code", "google_search", "scrape_and_extract_info"}

映射建好后由 set_tool_server_mapping(parsing_utils.py:62-72)缓存到模块级变量 _tool_server_mapping,在生成 system prompt 时就调一次(llm/providers/openai_client.py:365-368)。

第二步,改输出: 模型每次回复后,fix_server_name_in_text(parsing_utils.py:75-121)用缓存的映射去修文本里的 MCP XML。它在两个 LLM client 里都被调用(openai_client.py:295anthropic_client.py:228)。它做两件事:

  1. python 改名成 run_python_code(parsing_utils.py:99-105)——模型常把工具名简写成 pythonpython_code;
  2. 对 3 个目标工具,若 server 名不对就换成映射里的正确值(parsing_utils.py:108-119),用一条 re.sub 定位 <server_name>...</server_name> 紧跟目标 <tool_name> 的位置替换。

为什么只修 3 个工具?因为这是精准而保守的策略:全量改写风险大(可能误伤),而这三个是实测最高频、最影响任务成败的错——修它们性价比最高。


5. 解析层容错(先要读得懂)

修复和回滚都建立在一个前提上:先能把模型吐的东西解析成结构化的 tool_calls。可开源模型跑在不同后端上,格式各异,JSON 还经常是坏的。解析层就是把这堆乱七八糟统一"读懂"。

5.1 一个入口,三种格式

parse_llm_response_for_tool_calls(parsing_utils.py:311-433)用参数类型分派三种格式:

输入类型对应格式处理
dict(带 output)OpenAI Response API遍历 function_call 项(:330-351)
listOpenAI Completion API遍历 tool_call.function(:354-404)
strMCP-XML(qwen/anthropic 等)正则抓 <use_mcp_tool> 块(:407-433)

三条路最后都产出统一结构 {server_name, tool_name, arguments, id}。其中 server/tool 名的拆分用 name.rsplit("-", maxsplit=1):没有 - 就把 server_name 记为 "unknown"(:335-339)——这正是 3.4 里 Unknown tool: 回滚的上游来源之一。

5.2 坏 JSON 的三级抢救

模型给的 arguments 是 JSON 字符串,经常不合法(缺引号、Windows 路径里的反斜杠没转义……)。safe_json_loads(parsing_utils.py:193-225)三级兜底:

① json.loads(原文)
│ 失败

② repair_json(原文) 再 json.loads ← 第三方库 json_repair
│ 仍失败

③ 返回 {"error": "Failed to parse arguments", "raw": 原文}

第二级靠 json_repair 库(parsing_utils.py:216)自动补齐常见毛病。除此之外还有一个专门修反斜杠的辅助 _fix_backslash_escapes(parsing_utils.py:139-190),用几条保守的 re.sub 把 Windows 路径(C:\Users)、\1 这类没转义的反斜杠补成 \\——只修"明显有问题"的,不碰合法转义序列,避免越修越坏。

即使三级全挂,也不抛异常,而是返回一个带 error/raw 的字典。这个"永不崩"的设计很关键:解析失败不会掀翻整个循环,顶多让这轮变成一次可回滚的错误。

5.3 filter_none_values:去掉 None 参数

最后一道小工序:filter_none_values(parsing_utils.py:124-136)把 arguments 里值为 None 的键删掉:

return {k: v for k, v in arguments.items() if v is not None}

模型有时会给可选参数塞 null,有些工具的 schema 校验会因此报错;先滤掉,能少触发一批本可避免的执行错误。三种解析路径产出后都会过这一手。


6. 巧妙之处(可借鉴的技术)

  • 回滚不花轮次预算。 turn_count -= 1 抵消开头的 += 1,让模型能在同一轮号上多次纠错,而不透支任务的总步数(orchestrator.py:208:298)。
  • 护栏只盯"连续"失败,成功即归零。 consecutive_rollbacks 一旦成功执行就清零(orchestrator.py:1096-1102),所以长任务累计回滚几十次也没事——这是"能跑几百 turn"和"5 次就撞墙"的分水岭。
  • 同一个"废"字,不同触发不同善后。 达到上限时,格式错/拒答结束循环,重复查询放行(orchestrator.py:220-226 vs :308-314)。因为前者是模型真懵了,后者只是低效——区别对待才不误杀。
  • 空搜索也算"废"。 把 google_search 的空 organic 结果当成可回滚错误(tool_executor.py:162-191),逼模型换查询词而不是拿着空手继续——这是深度研究场景特有的、很实用的判定。
  • 修复只挑 3 个高频错工具。 TARGET_TOOLS 只含 3 个(parsing_utils.py:42):精准修高频错、不全量改写,把误伤风险压到最低。
  • 解析永不崩。 safe_json_loads 三级兜底、最终返回 error 字典而非抛异常(parsing_utils.py:222-225),把"坏 JSON"降级成一次可回滚的普通错误,而不是让整个 agent 崩掉。

7. 边界与局限(诚实)

  • 关键词表是硬编码、且有限的。 refusal_keywords 只列了 3 条(prompt_utils.py:78-82);模型换一种措辞拒答(比如中文拒答、或别的英文说法)就检测不到。这是"够用即可"的工程取舍,不是完备方案。
  • 去重是精确字符串匹配,不是语义去重。 get_query_str_from_tool_call 拼的是原始参数(tool_executor.py:102-134);查询词改一个空格或同义换词就绕过去重,系统会当成新查询放行。
  • 修复只覆盖 3 个工具。 其它工具的 server/参数错不在 fix_* 覆盖范围内,只能靠回滚兜底。
  • 护栏防的是"连续"而非"总量"。 若模型每隔几轮成功一次、又连续失败几次,consecutive_rollbacks 会反复清零,理论上可以长时间"边错边跑"——真正的总量上限来自 max_turns / max_attempts(见 02-orchestrator-loop.md)。

8. 横向对比 / 与其它章的关系

  • 循环骨架02-orchestrator-loop.md:turn_countmax_turns、主/子 Agent 的一轮怎么走。本章只讲"一轮里怎么判错、怎么回滚"。
  • 上下文与收尾04-context-and-answer.md:撞上限/失败后的失败经验重试、boxed 抽取——那是"整场任务失败后"的兜底,与本章"单轮回滚"互补(相关模板 FAILURE_SUMMARY_PROMPTprompt_utils.py:44-52)。
  • 工具即 MCP 服务05-tools-mcp.md:run_python_code 的 sandbox、google_search 等工具本体的实现。本章只讲对它们输出/参数的容错。
  • 总览与阅读地图见 index.md

9. 代码地图(导航索引)

用符号名 grep 比行号更抗漂移。

主题文件路径符号名
格式错/拒答回滚判定apps/miroflow-agent/src/core/orchestrator.py_handle_response_format_issues
重复查询回滚apps/miroflow-agent/src/core/orchestrator.py_check_duplicate_query
记录已执行查询apps/miroflow-agent/src/core/orchestrator.py_record_query
连续回滚上限常量apps/miroflow-agent/src/core/orchestrator.pyDEFAULT_MAX_CONSECUTIVE_ROLLBACKS
主循环内 result 回滚apps/miroflow-agent/src/core/orchestrator.pyshould_rollback_result(调用点 :1019)
工具结果是否该回滚apps/miroflow-agent/src/core/tool_executor.pyshould_rollback_result
google_search 空结果判定apps/miroflow-agent/src/core/tool_executor.pyis_google_search_empty_result
查询指纹提取apps/miroflow-agent/src/core/tool_executor.pyget_query_str_from_tool_call
参数别名/补 sandbox_idapps/miroflow-agent/src/core/tool_executor.pyfix_tool_call_arguments
server/tool 映射解析apps/miroflow-agent/src/utils/parsing_utils.pyparse_tool_server_mapping / set_tool_server_mapping
修正 server/tool 名apps/miroflow-agent/src/utils/parsing_utils.pyfix_server_name_in_text
三格式 tool_call 解析apps/miroflow-agent/src/utils/parsing_utils.pyparse_llm_response_for_tool_calls
坏 JSON 三级抢救apps/miroflow-agent/src/utils/parsing_utils.pysafe_json_loads / _fix_backslash_escapes
过滤 None 参数apps/miroflow-agent/src/utils/parsing_utils.pyfilter_none_values
MCP 标签 / 拒答关键词表apps/miroflow-agent/src/utils/prompt_utils.pymcp_tags / refusal_keywords