- Published on
Agent Harness 工程:错误恢复与重试
- Authors

- Name
- XiaoLeiJun
Agent Harness 工程:错误恢复与重试
上一篇将固定的 SYSTEM_PROMPT 拆成了运行时组装机制。每次调用模型前,Harness 会读取当前工具、权限模式、Todo、Skill、记忆和上下文摘要,再生成这一轮真正需要的提示词。
经过前几章的演进,模型输入已经比较完整,但调用过程仍然隐含着一个过于理想的假设:模型服务每次都会成功返回,而且返回内容一定完整。
真实运行中,这个假设很容易被打破。仅从单次模型 API 调用来看,最常见的故障大致有三类:
- 输出被截断:模型尚未完成输出,便耗尽了本轮输出 token;
- 上下文超出限制:即使已经做过常规压缩,最终请求仍然超过模型窗口;
- 临时故障:服务限流、过载、网关错误、连接中断或请求超时。
如果这些故障只能直接终止任务,Agent 的可靠性就会很低。但简单地“遇到错误就重试”同样不够,因为不同错误需要完全不同的处理方式:上下文超限不会通过等待自行恢复,权限拒绝也不应该通过重试绕过。
本章要解决的,不是简单增加一个 try/except,而是为 Harness 建立一层最小的错误恢复机制:先识别失败,再选择恢复动作,并为恢复设置明确上限。
失败不是同一种状态
这三类故障在接口层的表现如下。
| 故障类型 | Harness 看到的信号 | 合适的恢复动作 |
|---|---|---|
| 输出截断 | 请求成功,但 finish_reason == "length" | 提高输出预算后重发,或仅续写文本 |
| 上下文超限 | 请求失败,错误码表示 context length exceeded | 强制压缩上下文,然后重试一次 |
| 临时服务故障 | 429、5xx、超时或连接错误 | 指数退避加抖动,必要时切换备用模型 |
| 不可恢复的错误 | 认证失败、参数非法、模型不存在等 | 立即停止并暴露错误 |
这里最容易忽略的是:输出截断通常不是异常。
HTTP 请求可能成功,SDK 也不会抛错,只有响应里的 finish_reason 表明模型因为长度限制停止了输出。如果恢复逻辑只写在 except 中,就无法发现这类失败。
而上下文超限虽然会表现成异常,却不应该指数退避。请求内容不变,等 1 秒、2 秒或 8 秒都还是太长,真正需要改变的是输入。
该图只覆盖模型调用边界。错误恢复的第一步不是重试,而是分类:只有临时故障适合原样重试;上下文超限和输出截断都必须先改变请求。
从模型调用扩展到 Agent 运行时
对于“模型 API 请求为什么没有得到完整响应”这个问题,上述三类已经覆盖了最主要的可恢复故障。再加上认证失败、参数错误、模型不存在等不可恢复错误,模型调用层的分类就基本完整了。
但如果把观察范围扩大到整个 Agent 运行过程,还会遇到另外几类失败:
| 故障层次 | 典型表现 | 恢复方向 |
|---|---|---|
| 协议与输出错误 | 工具参数不是合法 JSON、缺少必填字段、结构化输出校验失败 | 拒绝执行,返回校验结果,要求模型重新生成 |
| 工具执行错误 | 工具超时、进程退出码非零、文件写入失败、远端服务不可用 | 根据只读或写入性质决定是否重试 |
| 无进展与死循环 | 重复调用同一工具、连续得到相同错误、Todo 长时间没有推进 | 识别失败指纹,要求重新规划,必要时停止 |
| 进程与状态中断 | Agent 崩溃、终端关闭、机器重启、会话被主动暂停 | 从持久化 checkpoint 或任务状态中恢复 |
这几类故障有两个特点。
第一,它们不一定表现为异常。 模型和工具的每一次调用都可能返回成功,但 Agent 仍然在重复同一个错误动作;从接口角度看一切正常,从任务角度看却已经失败。
第二,它们不能共用一套重试策略。 工具参数非法应该重新生成,任务无进展应该重新规划,进程中断则需要持久化状态。若将这些逻辑全部纳入 call_model_with_recovery(),模型调用层就会承担不属于它的职责。
因此,一个完整的 Harness 至少有四个恢复边界:
- 模型请求层:处理截断、上下文超限和临时服务故障;
- 响应协议层:解析并校验文本、工具调用和结构化输出;
- 工具执行层:处理权限、幂等、超时与外部副作用;
- 运行状态层:识别无进展循环,并在进程中断后恢复任务。
本文的实现聚焦第一层,同时明确第二、第三层的安全边界。对于第四层,本文只处理“重复失败”这一最小防护;真正的跨会话状态恢复,将由后续的 Task System 承接。
先排除不能自动重试的失败
实现恢复机制前,需要先明确边界。
下面这些情况不应该由 Harness 原样重试:
- 用户拒绝了权限申请;
- 权限策略明确禁止某个动作;
- 模型生成了不存在的工具名;
- 工具参数不符合 Schema;
- 请求使用了不存在的模型;
- API Key 无效或账户没有访问权限;
- 工具已经产生外部副作用,但返回结果不确定。
这些情况并非瞬时性故障,而是当前请求本身存在问题。原样重试无法消除问题,反而可能制造死循环,甚至绕过权限边界。
例如第 4 章里的权限拒绝,仍然应该作为 tool 消息写回模型:
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": "Error[permission_denied]: user denied permission.",
}
这样模型可以改用只读工具、调整方案,或者向用户解释为什么无法继续。Harness 不应该在后台再次发起同一项审批,更不能借助相似命令规避审批结果。
因此,错误恢复的核心并不是增加尝试次数,而是回答两个问题:
- 这次失败有没有可能在不改变任务语义的情况下恢复?
- 如果可以,应该改变等待时间、上下文,还是输出预算?
实现一个最小故障分类器
模型调用异常可以先分成三类:上下文超限、临时故障和不可恢复故障。
from enum import Enum
from openai import APIConnectionError, APITimeoutError
class ModelFailure(str, Enum):
CONTEXT_OVERFLOW = "context_overflow"
TRANSIENT = "transient"
FATAL = "fatal"
分类函数如下:
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
CONTEXT_ERROR_CODES = {
"context_length_exceeded",
"max_context_length_exceeded",
}
def classify_model_error(exc: Exception) -> ModelFailure:
error_code = getattr(exc, "code", None)
status_code = getattr(exc, "status_code", None)
if error_code in CONTEXT_ERROR_CODES:
return ModelFailure.CONTEXT_OVERFLOW
if status_code in RETRYABLE_STATUS_CODES:
return ModelFailure.TRANSIENT
if isinstance(
exc,
(APITimeoutError, APIConnectionError, TimeoutError, ConnectionError),
):
return ModelFailure.TRANSIENT
# 不同兼容服务暴露的错误结构并不完全一致,文本判断只作为兜底。
message = str(exc).lower()
if "context length" in message or "maximum context" in message:
return ModelFailure.CONTEXT_OVERFLOW
return ModelFailure.FATAL
这段示例保持了较小的实现规模,但有两个细节值得注意。
第一,优先读取结构化字段。 status_code 和 code 比错误文本更稳定。字符串匹配只适合兼容层兜底,不应该成为唯一判断依据。
第二,默认归为 FATAL。 未知错误不能仅凭可能恢复的猜测反复重试。在确认某类服务错误确实具有瞬时性之后,再将它显式加入重试规则。
不同模型服务的 SDK 异常类型并不完全相同。真实项目里,可以在 provider adapter 里把供应商特有异常统一转换成这三个类别,让 Agent Loop 不需要理解每家服务的错误结构。
输出截断没有放进这个分类器,因为它不是 Exception,随后需要从正常响应中单独检查。
临时故障:指数退避加抖动
对于 429、5xx、超时和连接错误,请求稍后重试确实可能成功。
固定间隔重试可以写成:
失败 -> 等 1 秒 -> 重试 -> 等 1 秒 -> 重试
但当很多 Agent 同时遇到服务故障时,它们也会在同一时刻再次发起请求,形成新的流量尖峰。因此更常见的做法是“指数退避加抖动”:等待窗口逐次扩大,再从窗口里随机选一个等待时间。
import random
def retry_delay(retry_index: int, base: float = 1.0, cap: float = 8.0) -> float:
upper_bound = min(cap, base * (2**retry_index))
return random.uniform(0, upper_bound)
如果 retry_index 从 0 开始,等待窗口大致会是:
第 1 次重试:0 ~ 1 秒
第 2 次重试:0 ~ 2 秒
第 3 次重试:0 ~ 4 秒
这里采用 full jitter(全抖动)。随机等待并不是为了缩短单个请求的恢复时间,而是为了避免大量请求在同一时间重新冲击服务。
如果服务响应中包含 Retry-After,应该优先遵循服务给出的等待时间。为突出核心流程,示例省略了响应头解析;实际实现仍应同时保留重试次数、指数退避和随机抖动。
上下文超限:强制压缩后只重试一次
第 9 章已经实现了两层上下文整理:
- 常规路径调用
compact(),控制工具结果和历史消息; - 异常路径调用
hard_compact_messages(),只保留摘要、最近消息和当前任务所需信息。
上下文超限可以直接接入这一兜底路径:
def force_context_compaction(
messages: list[dict],
state: HarnessState,
) -> None:
compacted = hard_compact_messages(
messages=messages,
state=state.context_manager,
)
messages[:] = compacted
使用 messages[:] = compacted 会原地更新当前任务的消息状态,而不是只为本次请求创建临时副本。
如果强制压缩后请求成功,后续轮次也应该继续使用已经压缩的历史;否则下一轮又会把旧的超长 messages 送回模型,再次触发同一个错误。
强制压缩最多重试一次。第二次仍然超限,通常说明问题不只是历史消息太长,还可能是:
- 当前用户一次性输入就超过窗口;
- 已加载 Skill 的正文太长;
- 运行时提示词里的记忆、项目说明或工具 Schema 没有预算;
- 当前模型的上下文窗口比预期更小。
此时应该停止重试并暴露诊断信息,而不是反复生成摘要。连续压缩会持续损失信息,也未必能处理真正占用 token 的内容。
这也进一步说明了上一篇 Prompt Assembler 的一个约束:动态 Section 不仅要按需加载,还应该分别设置长度预算。只压缩 messages,却让系统提示词无限增长,仍然会超出上下文窗口。
把异常恢复放进模型调用边界
接下来把分类、退避和强制压缩接入同一个模型调用函数。
首先保留一个只负责发送请求的函数:
def create_model_response(
user_message: str,
messages: list[dict],
state: HarnessState,
model: str,
max_output_tokens: int,
):
model_messages = build_model_messages(
user_message=user_message,
messages=messages,
state=state,
)
return client.chat.completions.create(
model=model,
messages=model_messages,
tools=state.tool_registry.schemas(),
max_tokens=max_output_tokens,
)
build_model_messages() 直接复用上一篇的实现:先根据当前运行态组装 system prompt,再让 Context Manager 整理历史消息。
这里沿用前文 OpenAI-compatible Chat Completions 的 max_tokens。如果当前 provider 使用 max_completion_tokens,只需要在 provider adapter 里转换参数名,不要让这类差异进入恢复循环。
再在其外层增加恢复循环:
import time
def call_model_with_recovery(
user_message: str,
messages: list[dict],
state: HarnessState,
model: str,
max_output_tokens: int,
max_transient_retries: int = 3,
):
context_retry_used = False
transient_retries = 0
while True:
try:
return create_model_response(
user_message=user_message,
messages=messages,
state=state,
model=model,
max_output_tokens=max_output_tokens,
)
except Exception as exc:
failure = classify_model_error(exc)
if failure is ModelFailure.CONTEXT_OVERFLOW and not context_retry_used:
print("[recovery] context overflow, forcing compaction")
force_context_compaction(messages, state)
context_retry_used = True
continue
if (
failure is ModelFailure.TRANSIENT
and transient_retries < max_transient_retries
):
delay = retry_delay(transient_retries)
transient_retries += 1
print(
"[recovery] transient failure, "
f"retry {transient_retries}/{max_transient_retries} "
f"in {delay:.2f}s"
)
time.sleep(delay)
continue
raise
该函数具备几个重要性质。
同一个入口重新构建请求。 上下文被强制压缩后,下一次循环会重新执行 build_model_messages(),所以 system prompt、上下文摘要和最近消息会重新组装,而不是复用已经超限的请求体。
恢复次数有上限。 临时故障最多重试 3 次,上下文超限最多强制压缩 1 次。超过预算后保留原始异常向上抛出。
不可恢复错误立即停止。 比如认证失败或参数错误会落到最后的 raise,不会经历无意义的等待。
保留原始错误信息。 最终抛出的仍然是最后一次真实异常,调用者可以记录错误码、请求 ID 和服务端响应,方便后续排查。
示例使用 print() 展示恢复动作。真实 Harness 应该把这些事件交给日志或 Trace 系统;即使尚未接入完整的可观测平台,也应该保留结构化恢复记录。
输出截断:先增大预算,再考虑续写
模型正常返回后仍需检查 finish_reason:
def response_was_truncated(response) -> bool:
return response.choices[0].finish_reason == "length"
发现输出截断后,应该优先提高本轮输出 token 上限,并基于相同输入重新生成一次。
def reissue_truncated_response(
user_message: str,
messages: list[dict],
state: HarnessState,
response,
current_budget: int,
output_cap: int = 8192,
):
if not response_was_truncated(response):
return response
if current_budget >= output_cap:
return response
next_budget = output_cap
print(
"[recovery] output truncated, "
f"increasing budget from {current_budget} to {next_budget}"
)
return call_model_with_recovery(
user_message=user_message,
messages=messages,
state=state,
model=state.model,
max_output_tokens=next_budget,
)
重新生成会增加一次模型调用,但其优势在于:模型会根据完整上下文重新组织回答,不需要在两段文本之间寻找拼接位置。
如果已经达到 output_cap,仍然因为长度被截断,可以对纯文本回答发起一次续写:
CONTINUE_PROMPT = """
上一条回答因为输出长度限制被截断。
请只从中断处继续,不要重复已经给出的内容,也不要重新开始回答。
""".strip()
def continue_truncated_text(
user_message: str,
messages: list[dict],
state: HarnessState,
response,
max_output_tokens: int,
) -> str:
message = response.choices[0].message
if message.tool_calls:
raise RuntimeError("truncated tool call must be regenerated, not continued")
partial_text = message.content or ""
recovery_messages = [
*messages,
{"role": "assistant", "content": partial_text},
{"role": "user", "content": CONTINUE_PROMPT},
]
continuation = call_model_with_recovery(
user_message=user_message,
messages=recovery_messages,
state=state,
model=state.model,
max_output_tokens=max_output_tokens,
)
continuation_message = continuation.choices[0].message
if continuation_message.tool_calls or response_was_truncated(continuation):
raise RuntimeError("text continuation did not finish cleanly")
return partial_text + "\n" + (continuation_message.content or "")
函数使用 recovery_messages 临时副本,没有把“请继续”永久写入任务历史。恢复成功后,Agent Loop 应该把拼接完成的文本作为一条 assistant 消息保存。
若将内部续写提示长期保留在 messages 中,后续的上下文摘要可能把它误认为用户真实意图,从而污染任务历史。
工具调用被截断时不能续写
文本可以从断点继续,工具调用不行。
工具参数通常是一段结构化 JSON。它被截断后可能缺少引号、右括号或关键字段。例如:
{"path":"src/app.py","content":"def main():
Harness 不能猜测剩余参数,更不能执行这个不完整的调用。正确做法是丢弃本次工具调用,提高输出预算后,让模型重新生成一份完整调用。
因此,截断恢复必须遵守一条明确边界:
纯文本可以续写;不完整的结构化输出必须整体重新生成并重新校验。
是否要切换备用模型
备用模型可以提高服务可用性,但不应该成为所有故障的默认兜底。
备用模型只适合在主模型持续出现临时故障时启用,而且至少要确认:
- 支持相同的工具调用协议;
- 能接收当前消息格式和 system prompt;
- 上下文窗口不小于当前请求;
- 输出 token 上限满足任务需要;
- 模型能力足以完成当前步骤。
备用模型的切换逻辑应该保持简单且显式:
try:
response = call_model_with_recovery(
user_message=user_message,
messages=messages,
state=state,
model=state.model,
max_output_tokens=2048,
)
except Exception as exc:
can_fallback = (
classify_model_error(exc) is ModelFailure.TRANSIENT
and state.fallback_model is not None
)
if not can_fallback:
raise
response = call_model_with_recovery(
user_message=user_message,
messages=messages,
state=state,
model=state.fallback_model,
max_output_tokens=2048,
max_transient_retries=1,
)
备用模型使用更少的重试次数,是为了控制整条恢复链的总预算。若主模型重试 3 次、备用模型再重试 3 次,外层任务又从头执行,一次故障就可能被放大成大量请求。
备用模型也不适合直接处理上下文超限。如果备用模型的上下文窗口更小,切换后只会加重问题;这类故障应该先调整输入。
模型调用重试,不等于工具执行重试
前文主要讨论模型 API 调用。接下来展开故障分类中的另一层:工具执行失败。
两者必须采用不同的恢复策略,因为工具可能产生真实副作用。
模型调用失败时,Harness 还没有执行模型请求的工具;工具执行阶段则可能已经写文件、发消息或创建远端资源,不能沿用同一套盲重试策略。
工具故障可以分为以下几种情况:
| 工具情况 | 是否可以自动重试 | 原因 |
|---|---|---|
| 只读工具明确超时 | 可以有限重试 | 不会改变外部状态 |
| 写操作明确在执行前失败 | 视工具幂等性决定 | 需要确认没有产生部分写入 |
| 写操作超时,结果未知 | 不应盲目重试 | 第一次调用可能已经成功 |
| 权限拒绝、参数非法、未知工具 | 不应原样重试 | 请求本身需要调整 |
假设工具是“发送邮件”。请求超时并不等于邮件没有发出。如果 Harness 立即重试,用户可能收到两封一模一样的邮件。
最小实现可以对同一个 tool_call.id 缓存结果:
tool_result_cache: dict[str, str] = {}
def execute_tool_call_once(tool_call) -> str:
if tool_call.id in tool_result_cache:
return tool_result_cache[tool_call.id]
result = execute_tool_call(tool_call)
tool_result_cache[tool_call.id] = result
return result
由此,同一进程内重复处理同一个工具调用时,不会再次执行真实工具。
需要注意,tool_call.id 只能识别“同一个调用”。如果模型重新生成语义相同的工具调用,它通常会得到新的 ID。对于创建工单、付款、发消息这类外部写操作,还需要业务级幂等键和持久化执行记录,不能只依赖内存缓存。
把恢复接回 Agent Loop
接入恢复机制后,Agent Loop 的主路径仍然可以保持简洁。模型调用部分可以收敛成下面几步:
def request_agent_response(
user_message: str,
messages: list[dict],
state: HarnessState,
):
output_budget = 2048
response = call_model_with_recovery(
user_message=user_message,
messages=messages,
state=state,
model=state.model,
max_output_tokens=output_budget,
)
if not response_was_truncated(response):
return response, None
response = reissue_truncated_response(
user_message=user_message,
messages=messages,
state=state,
response=response,
current_budget=output_budget,
)
if not response_was_truncated(response):
return response, None
recovered_text = continue_truncated_text(
user_message=user_message,
messages=messages,
state=state,
response=response,
max_output_tokens=8192,
)
return response, recovered_text
正常情况下,第二个返回值是 None;只有达到输出上限后触发了内部续写,它才会带回拼接完成的文本。若被截断的是工具调用,continue_truncated_text() 会直接拒绝续写,因此半截参数不会进入工具执行层。
原有 run_loop() 只需要把直接调用模型的位置替换为:
response, recovered_text = request_agent_response(
user_message=user_message,
messages=messages,
state=state,
)
if recovered_text is not None:
messages.append({"role": "assistant", "content": recovered_text})
return recovered_text
assistant_message = response.choices[0].message
其余逻辑仍然和前几章一致:
- 如果没有
tool_calls,把最终文本写入messages并返回; - 如果存在
tool_calls,先保存完整的 assistant 消息; - 逐个执行工具,把结果按
tool_call_id写回; - 权限拒绝和工具错误也作为结果交给模型;
- 进入下一轮,由模型决定调整参数、换工具还是停止。
错误恢复层负责“模型请求能否得到完整响应”,Agent Loop 负责“取得响应后如何继续任务”。两层职责应该保持分离。
给恢复过程设置总预算
除了限制每一种恢复动作,整次 Agent 运行还应该设置总预算。
初始实现可以采用下面的上限:
| 恢复动作 | 建议上限 |
|---|---|
| 临时故障重试 | 3 次 |
| 强制上下文压缩 | 1 次 |
| 提高输出预算重新生成 | 1 次 |
| 纯文本续写 | 1 次 |
| 切换备用模型 | 1 次 |
| Agent Loop 总步数 | 8~20 步 |
具体数字可以按任务调整,关键是每层都有终止条件。
此外,可以记录“失败指纹”:错误类型、工具名、参数摘要和错误码的组合。如果同一个指纹连续出现,即使总步数尚未耗尽,也应该停止原样重试,让模型调整方案,或者把问题交给用户。
缺少这类检测时,很容易出现下面的循环:
模型调用工具 -> 工具返回同一个错误 -> 模型再次调用同一个工具
-> 工具返回同一个错误 -> 模型再次调用同一个工具
max_steps 是最后一道保险,但系统应该在重复失败刚出现时就将其识别出来。
恢复也必须可观测
恢复成功不代表什么都没有发生。
如果一次任务最终完成了,但中间经历了 3 次限流、1 次强制压缩和 1 次备用模型切换,这些信息对性能、成本和稳定性分析都很重要。
至少应该记录:
run_id和当前 step;- 故障类型与原始错误码;
- 当前使用的模型;
- 这是第几次尝试;
- 采取了什么恢复动作;
- 实际等待了多久;
- 压缩前后的 token 估算;
- 最终是恢复、降级还是失败。
日志不应该直接记录完整提示词、用户敏感内容和 API Key。可观测不等于把所有数据原样落盘,日志同样需要脱敏和长度限制。
常见坑
第一,把所有异常都重试。 401、参数错误、权限拒绝不会因为多试几次变好,默认停止比默认重试更安全。
第二,只在 except 里做恢复。 输出截断是正常响应,必须检查 finish_reason。
第三,对上下文超限做指数退避。 输入不变,等待没有意义;应该先强制压缩并重新组装请求。
第四,无限续写。 模型可能每次都在输出上限处停止。续写也要有次数限制,必要时要求模型缩短回答。
第五,执行被截断的工具参数。 不完整的结构化输出必须整体重发、重新解析和重新校验。
第六,盲目重试有副作用的工具。 超时只表示没有收到结果,不表示操作没有发生。写操作需要幂等键和执行记录。
第七,备用模型和主模型能力不一致。 不支持工具调用、上下文窗口更小或消息格式不同的备用模型,会把一次临时故障变成新的逻辑错误。
第八,恢复成功后不记录。 被自动掩盖的故障仍然会消耗时间和 token,也可能预示服务质量正在下降。
第九,把“每次调用都成功”当成“任务正在推进”。 Agent 可能没有抛出任何异常,却一直重复同一个动作。恢复层还要观察 Todo、工具参数和失败指纹是否发生变化。
小结
本文为 Agent Harness 增加了最小错误恢复层:
- 区分模型调用层的输出截断、上下文超限、临时故障和不可恢复错误;
- 对临时故障使用有上限的指数退避和随机抖动;
- 对上下文超限强制压缩,并且只重试一次;
- 对截断输出先增加 token 预算,纯文本才允许有限续写;
- 不执行被截断的工具调用,而是整体重新生成并重新校验;
- 只在能力兼容时切换备用模型,并控制整条恢复链的预算;
- 把模型调用重试和工具执行重试分开,写操作依赖幂等机制;
- 识别协议错误、工具错误、无进展循环和进程中断等 Agent 运行时故障;
- 记录每次恢复事件,让“最终成功”背后的故障仍然可见。
至此,Agent 已经可以在当前运行过程中处理一部分故障,不再因为一次限流、一次超长上下文或一次截断响应就立即退出。
但它处理的任务仍然是“一次性”的:用户交给它一个任务,它在当前会话里维护 Todo、调用工具、给出结果,然后结束。TodoWrite 更像当前运行过程中的行动清单,它没有稳定的任务生命周期,也没有表达任务依赖,更不能在进程退出后仅凭内存状态恢复工作。
下一章讲 Task System:把任务从一份 Todo 列表升级成一张有依赖、有状态、可持久化的任务图。任务会有稳定 ID、依赖关系和状态流转,保存到磁盘后可以跨会话恢复,也能被不同 Agent 领取、执行和交接。这才是多 Agent 协作真正需要的任务基础设施。