出错是常态,不是异常
一个长时间运行的 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=TrueEdit/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 与技能市场,所有内容均经过真实企业级工作流验证。没有噱头,只有真正有效的东西。
更多实用知识和有趣产品,欢迎访问我的个人主页