Code Agent 解剖(14):Harness 设计之四——容错与恢复

出错是常态,不是异常

一个长时间运行的 agent,错误是必然会遇到的:模型偶尔返回空内容、上下文超限、网络抖动、工具执行到一半进程崩了......

问题不是"怎么避免出错",而是出错之后 agent 该怎么办。这一篇解剖 MyCodeAgent 的容错体系:运行时的分级重试、持久化的事实日志、以及崩溃后的状态恢复。


结论先说

容错体系分两个维度:

维度 机制 解决的问题
运行时自愈 错误分类 + 分级重试 模型出错时不立即放弃,先尝试自动恢复
崩溃恢复 Transcript 事实日志 + ResumeLoader 进程崩溃后能从中断点接续,不需要从头来

两个维度互相配合:运行时自愈处理可预期的错误,Transcript 保证即便自愈失败、进程崩溃,也不会丢失任何已完成的工作。


一、错误分类:先搞清楚是哪类错

python 复制代码
# runtime/model_errors.py
class ModelErrorKind(str, Enum):
    EMPTY_RESPONSE    = "empty_response"    # 模型返回空内容,没有文字也没有 tool_call
    PROMPT_TOO_LONG   = "prompt_too_long"   # 上下文超限,模型拒绝处理
    MAX_OUTPUT        = "max_output"        # 模型输出被截断(finish_reason="length")
    API_ERROR         = "api_error"         # RuntimeError,通常是 API 层问题
    UNKNOWN_MODEL_ERROR = "unknown_model_error"

classify_model_error() 接受异常对象或响应元数据,输出带 recoverable 标记的分类结果。分类是重试决策的前提------不同错误的恢复策略完全不同,不加区分地重试只会浪费配额。

python 复制代码
def _looks_like_prompt_too_long(message: str) -> bool:
    # 用关键词匹配,不依赖具体异常类型
    # 原因:不同 provider 的报错格式不一致,但错误消息里通常含有这些词
    patterns = ("prompt too long", "context length", "context window",
                "too many tokens", "maximum context length", "request too large")
    return any(pattern in message for pattern in patterns)

字符串匹配而非异常类型------这是刻意的设计。各家 LLM provider 抛出的异常类不同,但错误消息里的措辞高度一致,用关键词匹配更有普适性。


二、运行时自愈:三类错误,三条恢复路径

错误发生在 loop 内层的 while True 里,恢复成功就 continue 重试当前步,不消耗外层步骤配额。

路径 1:PROMPT_TOO_LONG → 压缩后重试

scss 复制代码
模型调用抛异常 → classify → PROMPT_TOO_LONG
  → reactive_compact()(对旧历史 LLM 摘要,产生 checkpoint)
  → build_model_view()(读时投影,拿到压缩后的消息列表)
  → 内层 continue,重新 invoke_raw()
  → 压缩失败 → MODEL_RECOVERY_FAILED → 终止

重试上限是 1 次(_get_model_recovery_limit 里写死)。压缩本身也可能失败(LLM 调用超时、轮次不够),失败则直接走终止路径,不会死循环。

路径 2:EMPTY_RESPONSE → 注入提示后重试

模型返回了空内容(response_text 为空且没有 tool_calls),原因通常是模型"犹豫了"------不确定该继续用工具还是给最终答案。

python 复制代码
if classification.kind is ModelErrorKind.EMPTY_RESPONSE and retry_count < retry_limit:
    hint = "上次 content 为空且未返回 tool_calls,请在 content 中回复最终答案,或使用工具调用。"
    # 把提示追加到本轮消息末尾,注意不写入 history_manager
    # 只影响这次重试的 model view,不污染永久历史
    messages = base_messages + [{"role": "user", "content": hint}]
    continue  # 内层重试

注意提示消息不写入 history_manager,只临时附加到本次发送的消息列表。这样历史是干净的,下一步模型不会看到这条"调试提示"。

重试上限默认 1 次(可通过 empty_response_retry_limit 配置)。

路径 3:MAX_OUTPUT → 当前不恢复

finish_reason="length" 说明模型输出被强制截断。理论上可以用"续写"策略恢复(把截断的内容补全),但当前实现的恢复上限是 0:

python 复制代码
if kind is ModelErrorKind.MAX_OUTPUT:
    return int(getattr(host, "max_output_recovery_limit", 0) or 0)
    # 返回 0 → retry_count >= retry_limit 立即成立 → 直接终止

实际上等于不重试,走 MODEL_ERROR 终止。这是 MVP 留的位置------结构预留了,策略尚未实现。


三、Transcript:append-only 事实日志

运行时自愈处理的是"可预期的错误"。但进程直接崩溃怎么办?这时候需要一份持久化的事实日志,能从中还原"崩之前跑到哪了"。

python 复制代码
# runtime/transcript.py
class TranscriptStore:
    """每个 session 一个 JSONL 文件,每行一个事件,只追加不修改。"""

    def append_event(self, event: TranscriptEvent) -> TranscriptEvent:
        with self._lock:
            self._repair_trailing_record()  # 先修复可能的不完整末行
            with self.path.open("a", encoding="utf-8") as handle:
                handle.write(line)
                handle.write("\n")
                handle.flush()  # 每次写入立即 flush,降低数据丢失窗口

五种事件类型覆盖了 loop 的所有关键事实:

事件类型 记录内容 恢复时用途
MESSAGE 消息角色 + 内容 + metadata 重建 HistoryManager
STATE_TRANSITION 转移原因 + 详情 重建最后的 LoopState
TOOL_LIFECYCLE 工具四阶段状态 判断哪些工具未完成
CHECKPOINT 压缩摘要 + 分割点 重建 CompactStore
TERMINAL 终止原因 判断是否正常完成

_repair_trailing_record() 处理一个特殊情况:进程在 write\n 之间崩溃,文件末尾留下一行残缺 JSON。每次写入前检查并截掉这行,保证文件每一行都是完整的 JSON:

python 复制代码
def _repair_trailing_record(self) -> None:
    if data.endswith(b"\n"):
        return  # 正常结尾,不需要修复
    # 末尾没有换行:尝试解析最后一行
    try:
        json.loads(tail.decode("utf-8"))
        # 解析成功:只是缺换行,补上就行
        with self.path.open("ab") as handle:
            handle.write(b"\n")
    except (UnicodeDecodeError, json.JSONDecodeError):
        # 解析失败:这行是残缺写入,直接截掉
        handle.truncate(tail_start)

四、ResumeLoader:从事件流重建状态

崩溃重启后,ResumeLoader 把 JSONL 里的事件流重新"播放"一遍,还原出 ResumeState

python 复制代码
# 遍历所有事件,按类型分别处理
for event in events:
    if MESSAGE:    → history_messages.append(...)
    if CHECKPOINT: → checkpoint = payload(稍后传给 CompactStore)
    if TERMINAL:   → terminal = payload(判断是否正常结束)
    if TOOL_LIFECYCLE: → tool_events[(run_id, tool_call_id)] 累积各阶段状态

工具状态的处理是最复杂的部分,因为一个工具调用有四个阶段,崩溃可能发生在任意一个阶段之间:

复制代码
requested → started → completed / failed

ResumeLoader 对每个工具调用的状态集合做出四种判断:

状态集合 含义 恢复处理
包含 completed 已成功完成 completed_tool_results,不重放
包含 failed 已失败 failed_tool_results,不重放
只有 requested,没有 started 请求了但还没开始执行 pending_tool_calls,可重新触发
started,但无 completed/failed 开始执行但结果不知道 uncertain_actions

五、UncertainAction:不确定性的显式建模

started 但无结果是最棘手的情况------工具执行了,但不知道成功了没有。

python 复制代码
UNSAFE_UNCERTAIN_REPLAY_TOOLS = {"Edit", "Bash", "Task"}

uncertain_actions.append(
    UncertainAction(
        tool_name=tool_name,
        tool_call_id=tool_call_id,
        step=step,
        replay_allowed=tool_name not in UNSAFE_UNCERTAIN_REPLAY_TOOLS,
    )
)

replay_allowed 按幂等性分类:

  • Read/Grep/Glob:幂等,重放没有副作用,replay_allowed=True
  • Edit/Bash/Task:有副作用,不知道上次有没有成功,贸然重放可能重复修改文件或执行命令,replay_allowed=False

不确定性不被静默处理,而是显式暴露给用户:CLI 恢复时会打印 uncertain actions 列表,让用户决定是否继续。这是一种"透明容错"------框架不假装知道发生了什么,而是如实告知。


设计亮点

1. 分类先于重试

所有恢复路径都从分类开始,不同错误走不同策略。这避免了最常见的容错反模式:对所有错误无脑重试,结果把临时错误和不可恢复错误都消耗在重试上。

2. 内层 while 隔离重试

重试发生在内层循环的 continue,外层步骤计数器 step 不变。max_steps=50 的配额完全用在有效的 ReAct 迭代上,不被错误恢复消耗。

3. Transcript append-only,从不修改

HistoryManager 的设计原则一致:只写入,从不修改历史事件。这保证了事件流的完整性------崩溃重启后重建的状态与崩溃前完全一致,没有"修复时引入新问题"的风险。

4. 不确定性显式建模

uncertain actions 不是实现细节,是设计概念。它承认"有些情况框架无法自动恢复",与其静默跳过或假装恢复,不如如实告知用户,让人来判断。


小结

设计选择 方案 工程价值
错误分类 关键词匹配 + recoverable 标记 跨 provider 兼容,分类先于重试
重试隔离 内层 while + continue 重试不消耗步骤配额
持久化 append-only JSONL + flush 崩溃窗口小,文件始终可重建
末行修复 _repair_trailing_record 应对 write/flush 之间崩溃的边界情况
不确定性 UncertainAction + replay_allowed 副作用工具不盲目重放,透明告知用户

关于本系列的源码

本系列所有分析均基于开源项目 MyCodeAgent

源码里已经按照本系列文章的讲解顺序,在关键位置加入了配套注释------读文章时可以对照代码,也可以直接克隆下来自己跑、改、扩展,基于它开发你自己的 agent。

bash 复制代码
git clone https://github.com/chendongqi/MyCodeAgent
cd MyCodeAgent
cp .env.example .env   # 填入你的 LLM API key
uv sync
uv run python main.py

欢迎访问 PrimeSkills ------ 一个精心策划的 AI Agent 与技能市场,所有内容均经过真实企业级工作流验证。没有噱头,只有真正有效的东西。

更多实用知识和有趣产品,欢迎访问我的个人主页

相关推荐
冬奇Lab15 分钟前
一天一个开源项目(第202篇):Needle 2 - 14MB 的端侧工具调用模型
人工智能·开源·资讯
林澈在路上21 分钟前
AI翻唱软件哪个好 2026国产AI写歌工具对比推荐
大数据·人工智能·深度学习·github·aigc·音视频·音频
咖啡星人k25 分钟前
2026 MCP 模型上下文协议:让 AI 自己动手接工具,告别手写接口(MonkeyCode 实战)
人工智能·深度学习·机器学习·语言模型·自然语言处理
yyuuuzz1 小时前
记一次 VPS 性能排查:共享 CPU 突发额度导致的限流
运维·服务器·人工智能
恋猫de小郭1 小时前
看懂大模型架构术语,帮助你理解目前常见的大模型开源架构
前端·人工智能·ai编程
tachibana21 小时前
复杂任务怎么做的任务拆分?
人工智能·ai·大模型·llm·agent
tachibana21 小时前
ReAct、Plan-and-Execute、Reflection 三种范式有什么核心区别
人工智能·ai·大模型·llm·agent
————A1 小时前
Agent 接收用户上传文件
人工智能·笔记·python·状态模式
Huazhongzhanhui1 小时前
极智防护与绿色表改:2026中国(武汉)国际表面处理展览会涂装涂料展会前瞻
人工智能·云计算