Agent 工程学习站 做棵大树 出品 · beatree.cn 免费 · 无需登录 · 进度存本机
II 版 · Harness 工程 第 08 章 Long-Horizon Reliability
II · Harness 工程 08 / 22 进阶

长任务与失败恢复

Long-Horizon Reliability
预计阅读 36 分钟
难度 进阶
关键词 流式卡死 · 状态机
本机状态 未读

跑着跑着模型不吐字了、上下文满了、进程被用户关掉了、同一个工具超时了三次——这些才是 Harness 工程师每天面对的东西。它们有个共同特点:都不是「模型不够聪明」,而是「系统没设计好」。这一章处理的就是这类问题,也是「工程问题」与「研究问题」的分水岭。

编辑部注 · 本章是全站最贴近真实面试考点的一章。如果你只能读完一章然后去面试,读这一章。

先建立一个分类标准。当一个 Agent 出问题时,你要能在三秒内判断它属于哪一类:

问题分类(这张表决定了你答题的类型是否错位)
类别典型问题该怎么解归属
工程问题流式响应卡死、上下文窗口超限、进程中断后无法恢复、工具超时后状态不一致检测 + 状态机 + 检查点 + 幂等,全是确定性的系统设计Harness
产品问题用户不知道该问什么、产物格式不满足要求、权限申请流程太长交互设计、默认值、引导产品
研究问题模型推理能力不足、幻觉、长链任务中目标漂移、指令跟随不稳训练、数据、结构改进模型团队
一个真实且昂贵的教训

有位候选人面试某大模型公司的 Harness 岗,一面通过,二面落选。原因是他被问「做 Agent 遇到过哪些工程挑战」时,答的是 long-horizon 能力不足减少幻觉。面试官的评价是:「这些都是研究问题,不是工程问题。」

他后来才想明白对方想听的是什么:换 provider 的成本与兼容、模型输出卡死后怎么自动恢复、上下文窗口满了怎么自动压缩。

这三个答案有共同特征——它们都是「不管模型多聪明都会发生,而且只能靠系统设计解决」的问题。答研究问题不会让你显得不懂技术,但会让你显得没在真实的系统里干过活

一、流式卡死:最常见的线上事故

先说现象。流式响应可能以多种方式「卡住」:完全没有数据返回;返回了几个 token 后停住;返回了内容但没有结束标记;连接还活着但服务端不吐字。

这些现象背后是不同的原因,但处理策略可以统一。 关键在于:你的 Harness 必须有一套与「模型是否合作」无关的检测与恢复机制。

流式卡死:从检测到恢复 检测:三个独立的心跳信号 ① 首字节超时(TTFB > 15s)  说明请求可能都没被接住 ② 字节间超时(> 20s 无新 chunk)  说明生成中途停住(最常见) ③ 总时长超时(> 上限)  兜底,防止缓慢但持续的消耗 判定:能不能安全重试 关键问题:已经收到的部分内容怎么办? 纯文本生成 → 丢弃部分内容,整段重试  (无损,最安全) 已发生副作用 → 不能整段重试!  必须靠幂等键判重,或改为断点续跑 重试次数上限:2-3 次,且要退避 恢复:一张必须显式的状态机 IDLE STREAMING VALIDATING EXECUTING DONE RETRYING (≤3) RECOVERING FAILED_SAFE RECOVERING 的含义:切换 provider / 降低输出长度 / 关闭流式 / 换更小或更大的模型 —— 先想办法把活干完,而不是立刻报错。 两条铁律 ① 所有网络调用都必须有超时 + 重试 + 退避,包括流式响应的「字节间超时」——只设总超时会让你在卡死上等满全程。 ② 状态机必须持久化:进程重启后要能从检查点继续,而不是从头再来(甚至重复副作用)。
图 1 流式卡死的完整处理链。最容易漏掉的是「字节间超时」——只设总超时的实现在「服务端每 60 秒吐一个字」的情况下会一直等下去,看起来没超时,实际上任务永远不会完成。

二、上下文自动压缩:让长任务能跑完

第 4 章讲了压缩策略,这里讲怎么把它做成一个自动机制,因为手动触发在长任务里不现实。

auto_compact.pypython
import hashlib, json
from dataclasses import dataclass, field
@dataclass
class CompactionPolicy:
    window: int = 128_000
    trigger_ratio: float = 0.60      # 到 60% 就开始压,留出余量
    keep_recent_turns: int = 4       # 最近几轮原样保留
    max_single_obs_ratio: float = 0.10   # 单条工具结果不得超过窗口的 10%
    summarize: object = None         # 注入的摘要函数(调用模型)
@dataclass
class Step:
    turn: int
    tool: str
    status: str
    brief: str                       # 该步的一句话说明
    result_fingerprint: str          # 结果指纹,用于判重与去噪
    full_output: str | None = None   # 完整输出:压缩后丢弃,只留摘要
def estimate_tokens(messages: list[dict]) -> int:
    """粗略估算。生产环境请用真实 tokenizer,这里按字符数近似"""
    return sum(len(str(m.get("content", ""))) for m in messages) // 2
def trim_single_observation(obs: str, limit_chars: int) -> str:
    """
    单条超长结果的头尾保留裁剪。
    关键:必须显式标注省略了多少,否则模型会以为这就是全部内容。
    """
    if len(obs) <= limit_chars:
        return obs
    head = obs[: int(limit_chars * 0.6)]
    tail = obs[-int(limit_chars * 0.3):]
    omitted = len(obs) - len(head) - len(tail)
    return f"{head}\n……[此处省略 {omitted} 字符]……\n{tail}"
def compact(messages: list[dict], steps: list[Step], policy: CompactionPolicy) -> tuple[list[dict], str]:
    """
    返回 (压缩后的消息列表, 压缩事件说明)。
    三件事按顺序做:单条裁剪 → 旧步骤摘要 → 结论固化到头部。
    """
    events = []
    # ---- 第一步:单条超长结果裁剪(成本最低,先做) ----
    limit = int(policy.window * policy.max_single_obs_ratio * 2)   # 字符数近似
    for m in messages:
        if m.get("role") == "tool" and len(str(m.get("content", ""))) > limit:
            before = len(str(m["content"]))
            m["content"] = trim_single_observation(str(m["content"]), limit)
            events.append(f"裁剪单条工具结果 {before}{limit} 字符")
    # ---- 第二步:用量仍超阈值,则摘要旧步骤 ----
    if estimate_tokens(messages) > policy.window * policy.trigger_ratio:
        old = steps[:-policy.keep_recent_turns] if len(steps) > policy.keep_recent_turns else []
        if old:
            summary = structured_summary(old, policy.summarize)
            # 用摘要替换掉旧的具体消息,但保留最近几轮原文
            kept = messages[:3] + [{"role": "user", "content": f"【已完成步骤摘要】\n{summary}"}]
            kept += messages[-(policy.keep_recent_turns * 2):]
            messages = kept
            events.append(f"摘要压缩 {len(old)} 个步骤,上下文降至 {estimate_tokens(messages)} token")
    # ---- 第三步:把不可丢失的结论固化到头部(防止被后续压缩丢掉) ----
    pinned = [s for s in steps if s.status == "ok" and s.brief]
    if pinned:
        digest = ";".join(f"{s.tool}:{s.brief}" for s in pinned[-10:])
        # 头部插入「钉住的事实」,这部分不再参与后续压缩
        messages.insert(2, {"role": "user", "content": f"【已确认的关键结论】{digest}"})
    return messages, " | ".join(events) if events else "无需压缩"
def structured_summary(steps: list[Step], summarize_fn) -> str:
    """
    结构化摘要:固定四段。自由文本摘要会越摘越糊,
    而固定字段能保证「目标 / 已完成 / 结论 / 待办」都不丢。
    """
    done = [s for s in steps if s.status == "ok"]
    failed = [s for s in steps if s.status != "ok"]
    return (
        f"目标:见任务描述(未变更)\n"
        f"已完成({len(done)}):" + ";".join(f"{s.tool}{s.brief}" for s in done) + "\n"
        f"失败/已放弃({len(failed)}):" + ";".join(f"{s.tool}{s.status}" for s in failed) + "\n"
        f"待办:目标中尚未被任何步骤覆盖的部分"
    )
def fingerprint(text: str) -> str:
    return hashlib.sha256(text.encode()).hexdigest()[:12]
压缩机制里最重要的一步是「钉住」

压缩的常见失败方式是:把关键结论一起压掉了。跑了几十轮之后,模型忘了「用户一开始说要以 2025 年的口径为准」,于是后面的分析全部跑偏。

解法是分层:把上下文分成「可压缩区」和「不可压缩区」。不可压缩区放三类内容:原始任务目标、用户明确给出的约束、以及每一步确认过的关键结论。这个区域只追加、不压缩,并且放在最前面(顺带契合前缀稳定性原则)。

三、崩溃恢复:检查点与幂等

长任务会跨越进程生命周期。用户会关窗口、机器会重启、部署会滚动更新。任务必须能在中断后接上。

机制做什么关键细节
检查点每个步骤完成后持久化状态快照快照要包含:已完成步骤、钉住的结论、待办队列、外部副作用记录
幂等键每个副作用操作带一个稳定标识基于「任务 ID + 步骤序号」生成,恢复后重复执行会被识别并跳过
恢复语义定义「从哪继续」只重放未完成的步骤;已完成步骤的结果从快照读取,不重新执行
副作用日志记录每个已发生的副作用恢复时用它判断「这个操作到底做没做」,避免重复下单/重复发送
版本校验恢复前检查代码版本与工具集是否变更工具签名变了就不该静默续跑,应提示用户或重新规划
checkpoint.pypython
import json, os, time
from dataclasses import dataclass, asdict, field
STATE_DIR = os.environ.get("HT_STATE_DIR", "./.ht_state")
@dataclass
class Checkpoint:
    task_id: str
    goal: str                                  # 原始目标,永不压缩
    constraints: list[str]                     # 用户明确给出的约束,永不压缩
    pinned_conclusions: list[str]              # 已确认结论,只追加
    pending: list[str]                         # 待办
    side_effects: list[dict] = field(default_factory=list)  # 已发生的副作用
    version: int = 1
    updated_at: float = field(default_factory=time.time)
def save(cp: Checkpoint) -> None:
    """原子写:先写临时文件再重命名,避免中断导致快照损坏"""
    os.makedirs(STATE_DIR, exist_ok=True)
    path = os.path.join(STATE_DIR, f"{cp.task_id}.json")
    tmp = path + ".tmp"
    cp.updated_at = time.time()
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(asdict(cp), f, ensure_ascii=False, indent=1)
    os.replace(tmp, path)          # 原子替换
def load(task_id: str) -> Checkpoint | None:
    path = os.path.join(STATE_DIR, f"{task_id}.json")
    if not os.path.exists(path):
        return None
    with open(path, encoding="utf-8") as f:
        return Checkpoint(**json.load(f))
def idempotent_side_effect(cp: Checkpoint, step_no: int, action: str, run) -> dict:
    """
    所有副作用必须走这里。
    恢复后重复调用会命中 side_effects 记录,直接返回上次结果。
    """
    key = f"{cp.task_id}:{step_no}:{action}"
    for rec in cp.side_effects:
        if rec["key"] == key:
            return {"status": "already_done", "result": rec["result"]}
    result = run()
    cp.side_effects.append({"key": key, "action": action,
                            "result": result, "at": time.time()})
    save(cp)                    # 立即落盘:副作用发生后必须马上可查
    return {"status": "executed", "result": result}
def resume(task_id: str, current_tools: set[str]) -> tuple[Checkpoint, list[str]]:
    """
    恢复:只重放未完成步骤,并检查工具集是否变更。
    如果某个待办步骤依赖的工具已经不存在,必须显式报告而不是静默跳过。
    """
    cp = load(task_id)
    if cp is None:
        raise FileNotFoundError(f"没有找到任务 {task_id} 的检查点")
    problems = []
    for step in list(cp.pending):
        tool = step.split("::")[0] if "::" in step else step
        if tool not in current_tools:
            problems.append(f"待办步骤依赖的工具 {tool} 已不可用,需重新规划:{step}")
    return cp, problems

四、三个常被忽略的稳定性问题

第一,provider 切换。 不要假设只有一个模型供应商。做到两点:把所有调用收敛到一个适配层(换 provider 只改一处);并且用「能力探测」而不是「硬编码模型名」来决定用哪个模型(例如不支持工具调用的模型要自动降级为纯文本模式)。

第二,输出解析的宽容度。 模型可能返回带 Markdown 代码块包裹的 JSON、尾随逗号、单引号、注释。解析器要能容忍这些常见变体,而不是直接抛异常。宽容解析 + 严格校验的组合,比严格解析 + 宽容校验有效得多。

第三,时间与预算的耦合。 用户的等待耐心和你的预算上限不是同一件事。建议分别设:软超时(触发降级策略,比如缩小输出规模、跳过可选步骤)和硬超时(直接返回部分结果)。只有硬超时会导致任务失败。

五、自测

本章自测本章三题全部为真实面试高频题
Q1只设置「总响应超时」有什么问题?
C。这是流式处理必须知道的一条:总超时测的是「总时长」,而卡死的特征往往是「长时间没有任何新数据」。所以必须同时设 TTFB 超时和字节间超时(inter-chunk timeout)。这个细节能答出来,基本能确认你真的写流式处理代码。
Q2上下文自动压缩最容易出问题的地方是?
B。D 确实是一种代价(压缩会改前缀),但可以通过「把不可压缩区放最前、只压后续内容」来缓解。真正的伤害是信息丢失:解法是上下文分层——不可压缩区(原始目标、用户约束、已确认结论)+ 可压缩区(过程性内容)
Q3长任务中断后恢复,最重要的设计是什么?
D。A 的问题是重放会重复执行已产生副作用的步骤(比如重复发邮件);B 更糟,等于重复所有副作用。C 是回避问题而非解决问题。正确思路:状态快照(记录已完成与副作用)+ 幂等键(让重复执行可被识别)——这两件事必须同时有,缺一不可。

六、小结

问题工程解法关键细节
流式卡死TTFB 超时 + 字节间超时 + 总超时,三级独立只设总超时无法检测「缓慢但持续」的卡死
输出不完整状态机 + 有限重试 + 退避已发生副作用时不能整段重试
上下文超限自动压缩:单条裁剪 → 旧步骤摘要 → 结论钉住上下文要分层,不可压缩区放最前
进程中断检查点 + 幂等键 + 副作用日志幂等键必须稳定(基于任务 ID + 步骤序号)
provider 变更适配层 + 能力探测不要硬编码模型名
解析失败宽容解析 + 严格校验兼容代码块包裹、尾随逗号等常见变体

「换 provider、卡死恢复、上下文压缩」这三个答案之所以被面试官认可,是因为它们都满足同一个条件:不管模型多聪明,这些问题都会发生,而且只能靠系统设计解决。本刊编辑部

Harness 工程部分到此结束。接下来进入一个更少人认真做、也更能体现系统思维的部分:怎么证明你的改动真的让系统变好了。

面试官会怎么问

共 4 条 · 其中 2 条高频 · 先自己答一遍,再展开对照

你做 Agent 的时候遇到过哪些工程挑战? 高频
这道题是分水岭:回答"减少幻觉、提升长程推理能力"是研究问题,会被判定站位不对。要答工程问题,例如:
① provider 侧不稳定——限流、超时、通道抖动,需要多通道抽象 + 健康探测 + 自动切换 + 切换后的结果一致性校验;
② 流式输出卡死——连接还在但不出 token,需要首 token 超时 + 空闲心跳超时 + 强制中断与重试;
③ 上下文溢出——主动计量、分级压缩、状态外置,并在触发时给用户可见提示;
④ 长任务中断恢复——进程被杀、机器重启,需要任务状态机 + 检查点 + 断点续跑;
⑤ 副作用重复——重试导致重复写库,需要幂等键与去重表;
⑥ 成本失控——预算上限、轮数上限、缓存友好前缀;
⑦ 变更风险——prompt 一改就翻车,需要版本化、灰度、自动回滚。
答完再补一句:这些问题的共同点是"模型没坏,是系统没兜住",这正是 Harness 存在的价值。

追问链

  1. 流式卡死具体怎么检测?阈值怎么定?
  2. 上下文自动压缩在架构上放在哪一层?
加分点:把答案归到"这是工程问题不是模型问题",就与岗位定位完全对齐了。
模型输出卡死(连接还在但不出 token)怎么发现、怎么恢复? 高频
三级超时 + 一次中断:
① 首 token 超时——从发起请求到收到第一个 token 的时间上限。这能抓住"请求已受理但模型排队/卡住"的情况,阈值取实测 P99 再放宽,比如 30–60 秒。
② 空闲心跳超时——两个相邻 token 之间的最大间隔。这能抓住"已经开始输出但中途挂住"的情况,阈值可以比首 token 短得多,比如 10–20 秒。
③ 总时长上限——整个响应的时间上限,防止"一直缓慢吐字"把任务无限拖下去。
任一触发就主动中断连接(不要等对端),记录现场(已收到的部分内容、耗时、阶段),然后按策略恢复:同一通道重试 → 换通道重试 → 降级到非流式 → 报错并把中间结果交出。
判断"慢"还是"死"的关键是心跳:只要还在稳定吐字就不算卡死,所以不能只看总时长。
加分点:说清"总时长上限和心跳超时抓的是不同故障",并强调不能只看总时长,这是关键细节。
长任务跑到一半崩了怎么办?
靠任务状态机 + 检查点,而不是靠重跑。
状态机至少要覆盖:排队、运行、等待人工确认、暂停、成功、失败、重试中、已取消——每个状态有明确的进入/退出条件与持久化内容。
检查点要落盘四样东西:任务目标与约束、已完成步骤清单、每步的产物指针、下一步计划。落盘时机选在"每个子目标完成之后"而不是固定时间间隔,因为子目标边界才是真正的可恢复点。
恢复流程是:读出检查点 → 校验产物是否已存在(避免重复执行)→ 从下一个未完成步骤继续,并把"之前发生过什么"用摘要注入上下文。
还有一个容易被忽略的点:重试不能重复副作用,所以恢复时必须先查状态再决定是否执行,而不是无条件重放。
你的系统怎么区分"工具超时了"和"工具执行失败了"?
这个区分很重要,因为处理方式完全不同:
执行失败意味着服务明确返回了错误(4xx/5xx、业务异常),说明请求已被处理且结果是失败——通常不该原样重试,要么修参数,要么换方案。
超时意味着我们只是没等到回应,请求可能成功了、可能失败了、也可能还在执行中——这叫"结果未知",比失败更危险,直接重试可能造成重复副作用。
所以正确做法是:对可能产生副作用的工具,超时后先走状态查询(用幂等键或业务 ID 查这次调用到底有没有生效),确认未生效才重试;如果工具没有查询接口,就不该给它自动重试的权限,只能上报人工处理。
这也解释了为什么设计工具时就要把幂等键和查询能力作为契约的一部分,而不是事后补。
加分点:把"结果未知比失败更危险"讲出来,是很有经验感的表达。
答这类题的通用结构

结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。

本章收尾 · Wrap up
掌握度自评
点一下给自己打分;低于 3 分建议加入复习队列
个人笔记 · Notes