跑着跑着模型不吐字了、上下文满了、进程被用户关掉了、同一个工具超时了三次——这些才是 Harness 工程师每天面对的东西。它们有个共同特点:都不是「模型不够聪明」,而是「系统没设计好」。这一章处理的就是这类问题,也是「工程问题」与「研究问题」的分水岭。
编辑部注 · 本章是全站最贴近真实面试考点的一章。如果你只能读完一章然后去面试,读这一章。
先建立一个分类标准。当一个 Agent 出问题时,你要能在三秒内判断它属于哪一类:
| 类别 | 典型问题 | 该怎么解 | 归属 |
|---|---|---|---|
| 工程问题 | 流式响应卡死、上下文窗口超限、进程中断后无法恢复、工具超时后状态不一致 | 检测 + 状态机 + 检查点 + 幂等,全是确定性的系统设计 | Harness |
| 产品问题 | 用户不知道该问什么、产物格式不满足要求、权限申请流程太长 | 交互设计、默认值、引导 | 产品 |
| 研究问题 | 模型推理能力不足、幻觉、长链任务中目标漂移、指令跟随不稳 | 训练、数据、结构改进 | 模型团队 |
有位候选人面试某大模型公司的 Harness 岗,一面通过,二面落选。原因是他被问「做 Agent 遇到过哪些工程挑战」时,答的是 long-horizon 能力不足和减少幻觉。面试官的评价是:「这些都是研究问题,不是工程问题。」
他后来才想明白对方想听的是什么:换 provider 的成本与兼容、模型输出卡死后怎么自动恢复、上下文窗口满了怎么自动压缩。
这三个答案有共同特征——它们都是「不管模型多聪明都会发生,而且只能靠系统设计解决」的问题。答研究问题不会让你显得不懂技术,但会让你显得没在真实的系统里干过活。
一、流式卡死:最常见的线上事故
先说现象。长任务(Long-horizon) 在流式阶段最容易出的事故就是 流式卡死(Streaming Stall):流式响应可能以多种方式「卡住」——完全没有数据返回;返回了几个 token 后停住;返回了内容但没有结束标记;连接还活着但服务端不吐字。检测它靠两个阈值:首字节时延(TTFB)和 字节间超时(Inter-chunk Timeout)。
这些现象背后是不同的原因,但处理策略可以统一。 关键在于:你的 Harness 必须有一套与「模型是否合作」无关的检测与恢复机制。
二、上下文自动压缩:让长任务能跑完
第 4 章讲了压缩策略,这里讲怎么把它做成一个自动机制,因为手动触发在长任务里不现实。这套机制的核心是 上下文自动压缩(Context Compaction),而最关键的一步是 结论钉住(Pinning)——把不可丢失的结论排除在压缩之外。
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 年的口径为准」,于是后面的分析全部跑偏。
解法是分层:把上下文分成「可压缩区」和「不可压缩区」。不可压缩区放三类内容:原始任务目标、用户明确给出的约束、以及每一步确认过的关键结论。这个区域只追加、不压缩,并且放在最前面(顺带契合前缀稳定性原则)。
2.1 压缩省 token,但可能把账单打贵
压缩看起来是「省钱」,但长任务里有一笔隐性账,第 2 章(llm-02)的缓存计费已经埋了伏笔,这里展开:
压缩破坏前缀,前缀破坏缓存命中。 长任务的每一轮,请求体都在「前缀 + 本轮新内容」上追加。如果某一轮在前缀部分做了压缩——把历史步骤重写成摘要、重新拼一遍消息——那么从改动处开始,整个前缀缓存作废,下一轮输入从「命中」变成「未命中」,单价可能翻数倍。
def compaction_cost_tradeoff(input_tokens: int, saved_tokens: int,
hit_price: float, miss_price: float) -> str:
"""
压缩省了 saved_tokens 个 token,但若压缩发生在前缀部分,
缓存从命中(hit)变成未命中(miss),单价上涨。
判断:省下的 token × 命中价 vs 重新计算的前缀 × (未命中价-命中价)。
"""
saved_value = saved_tokens * hit_price
# 最坏情况:整段前缀(压缩前的那部分)都要按未命中价重算
prefix = input_tokens - saved_tokens
extra_cost = prefix * (miss_price - hit_price)
if extra_cost > saved_value:
return f"得不偿失:省 {saved_tokens} token 值 {saved_value:.2f},却多付 {extra_cost:.2f}"
return f"值得:省 {saved_value:.2f},多付 {extra_cost:.2f}"三个可直接落地的结论:
- 压缩尽量只动「尾部」:最近几轮原样保留、旧步骤摘要化——如果摘要改写的是靠前的历史,就把前缀打碎了。更稳的做法是把摘要追加在尾部,而不是回头改写旧消息。
- 压缩时机要「成批、少次」:每轮都压一次 = 每轮都把前缀打碎一次,缓存永远命中不了。宁可攒到阈值一次性压,让两次压缩之间保持长段稳定前缀。
- 把「钉住区」放最前、永不重排:这一节前面讲的「不可压缩区」之所以强调「只追加、放最前」,不止是为了不丢结论,也是为了让这段稳定前缀始终可命中——它一动,后面全废。
一句话:压缩省的是「读的 token」,代价可能是「重算的 token」。在判断要不要压、压哪里之前,先问一句「这会动到前缀吗」——这是长任务成本控制里最容易漏的一环。
三、崩溃恢复:检查点与幂等
长任务会跨越进程生命周期。用户会关窗口、机器会重启、部署会滚动更新。任务必须能在中断后接上——靠的是显式的 状态机(State Machine) 与落盘的 检查点(Checkpoint)。
| 机制 | 做什么 | 关键细节 |
|---|---|---|
| 检查点 | 每个步骤完成后持久化状态快照 | 快照要包含:已完成步骤、钉住的结论、待办队列、外部副作用记录 |
| 幂等键 | 每个副作用操作带一个稳定标识 | 基于「任务 ID + 步骤序号」生成,恢复后重复执行会被识别并跳过 |
| 恢复语义 | 定义「从哪继续」 | 只重放未完成的步骤;已完成步骤的结果从快照读取,不重新执行 |
| 副作用日志 | 记录每个已发生的副作用 | 恢复时用它判断「这个操作到底做没做」,避免重复下单/重复发送 |
| 版本校验 | 恢复前检查代码版本与工具集是否变更 | 工具签名变了就不该静默续跑,应提示用户或重新规划 |
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 Abstraction)。 不要假设只有一个模型供应商。做到两点:把所有调用收敛到一个适配层(换 provider 只改一处);并且用「能力探测」而不是「硬编码模型名」来决定用哪个模型(例如不支持工具调用的模型要自动降级为纯文本模式)。
第二,输出解析的宽容度。 模型可能返回带 Markdown 代码块包裹的 JSON、尾随逗号、单引号、注释。解析器要能容忍这些常见变体,而不是直接抛异常。宽容解析 + 严格校验的组合,比严格解析 + 宽容校验有效得多。
第三,时间与预算的耦合。 用户的等待耐心和你的预算上限不是同一件事。建议分别设:软超时与硬超时(Soft/Hard Timeout)——软超时触发降级策略(缩小输出规模、跳过可选步骤),硬超时才直接返回部分结果并判定失败。只有硬超时会导致任务失败。
五、常见误区与追问
5.1 误区:任务跑不动就怪模型 long-horizon 能力不足
面试里最贵的教训:把工程问题答成研究问题。模型推理不足、幻觉、目标漂移是研究问题,得靠训练与数据;而换 provider 的成本、流式卡死恢复、上下文自动压缩是「不管模型多聪明都会发生、只能靠系统设计」的工程问题。判据:这个问题是否「换了更强的模型就消失」?会消失的是研究问题,不会的是工程问题。
5.2 误区:只设总响应超时就够了
总超时测的是「总时长」,而卡死的特征往往是「长时间没有新数据」。服务端每 60 秒吐一个字,总超时永远不触发,任务实际已死。必须同时设首字节时延(TTFB)与字节间超时两个独立阈值。数字上可以这样定:TTFB 30–60 秒、字节间 10–20 秒、总时长按用户耐心上限(如 5 分钟),三者独立触发,触发后的动作也不同——前两级中断后重试,第三级直接返回部分结果。
5.3 误区:上下文压缩就是截断
直接丢掉最早的 token,会把用户最初给的约束、已确认的关键结论一起删掉,后续推理整体跑偏。正确做法是上下文自动压缩配合分层与结论钉住:不可压缩区只追加、放最前。判据:压缩完成后回查三样东西还在不在——原始任务目标、用户明确给出的约束、已确认的关键结论;少任意一样,这次压缩就是在制造跑偏。
5.4 误区:崩溃后从头重跑最稳
从头重跑会重复执行已产生副作用的步骤(重复发邮件、重复下单)。正确做法是检查点加幂等键:只重放未完成步骤,已发生的副作用通过幂等键识别并跳过。判据:恢复前先查副作用日志,给每个待执行步骤算一次幂等键(任务 ID + 步骤序号),命中记录就跳过——做不到这一步,「重跑」就是重复下单。
5.5 误区:工具调用值得无限重试
已发生副作用的调用不能整段重试,否则重复下单。判据:重试次数上限 2–3 次且退避;已发生副作用时必须靠幂等键判重或改为断点续跑。数字上超过 2–3 次往往就不是网络抖动了,而是输入或工具本身有问题,继续重试只是把预算烧光,应该转人工或换方案。
5.6 误区:provider 写死模型名无所谓
硬编码模型名,换供应商就要改代码、重测、可能回归。正确做法是收敛到 provider 适配层,并用「能力探测」而非模型名决定走哪条路径(不支持工具调用的模型自动降级纯文本)。判据:在业务代码里搜一遍模型名与端点,出现即说明适配层没做——正确形态是业务代码只表达「要什么能力」,由适配层决定用哪个模型、走哪条路径。
六、自测
七、小结
| 问题 | 工程解法 | 关键细节 |
|---|---|---|
| 流式卡死 | TTFB 超时 + 字节间超时 + 总超时,三级独立 | 只设总超时无法检测「缓慢但持续」的卡死 |
| 输出不完整 | 状态机 + 有限重试 + 退避 | 已发生副作用时不能整段重试 |
| 上下文超限 | 自动压缩:单条裁剪 → 旧步骤摘要 → 结论钉住 | 上下文要分层,不可压缩区放最前 |
| 进程中断 | 检查点 + 幂等键 + 副作用日志 | 幂等键必须稳定(基于任务 ID + 步骤序号) |
| provider 变更 | 适配层 + 能力探测 | 不要硬编码模型名 |
| 解析失败 | 宽容解析 + 严格校验 | 兼容代码块包裹、尾随逗号等常见变体 |
「换 provider、卡死恢复、上下文压缩」这三个答案之所以被面试官认可,是因为它们都满足同一个条件:不管模型多聪明,这些问题都会发生,而且只能靠系统设计解决。本刊编辑部
Harness 工程部分到此结束。接下来进入一个更少人认真做、也更能体现系统思维的部分:怎么证明你的改动真的让系统变好了。
八、参考与延伸
本章的机制部分只讲到「够用」为止。想往下深挖,下面这几份材料按「先看图、再看代码、最后读规范」的顺序排好了。全站不做原文转载,这里只登记链接与「为什么值得读」。
先看图(建立直觉)
- Simon Willison · LLM notes —— 大量「长任务跑飞 / 流式中断 / 工具超时」的真实记录。翻他的 agent 与 tool-use 标签,能建立「工程问题 vs 研究问题」的直觉。
再看代码(动手实现)
- SWE-bench —— 长任务 Agent 的评测基座。看它的任务分片与重放机制,理解「为什么需要检查点与可恢复」。
- τ-bench —— 多步工具调用可靠性的评测。它的「用户中途介入」设定,正是本章人工确认 / 状态机要解决的问题。
最后读规范(对齐一手定义)
- Anthropic · Building Effective Agents —— 官方对长任务编排的建议。它把「流式、工具、恢复」当成一等公民,和本章的超时三级、检查点直接对应。
- OpenAI Platform 文档 —— 模型调用的官方能力说明。看它怎么描述流式、函数调用与超时,作为把本章机制落地的依据。
◇ 面试官会怎么问
共 7 条 · 其中 3 条高频 · 先自己答一遍,再展开对照
你做 Agent 的时候遇到过哪些工程挑战? 高频
① provider 侧不稳定——限流、超时、通道抖动,需要多通道抽象 + 健康探测 + 自动切换 + 切换后的结果一致性校验;
② 流式输出卡死——连接还在但不出 token,需要首 token 超时 + 空闲心跳超时 + 强制中断与重试;
③ 上下文溢出——主动计量、分级压缩、状态外置,并在触发时给用户可见提示;
④ 长任务中断恢复——进程被杀、机器重启,需要任务状态机 + 检查点 + 断点续跑;
⑤ 副作用重复——重试导致重复写库,需要幂等键与去重表;
⑥ 成本失控——预算上限、轮数上限、缓存友好前缀;
⑦ 变更风险——prompt 一改就翻车,需要版本化、灰度、自动回滚。
答完再补一句:这些问题的共同点是"模型没坏,是系统没兜住",这正是 Harness 存在的价值。
追问链
- 流式卡死具体怎么检测?阈值怎么定?
- 上下文自动压缩在架构上放在哪一层?
模型输出卡死(连接还在但不出 token)怎么发现、怎么恢复? 高频
① 首 token 超时——从发起请求到收到第一个 token 的时间上限。这能抓住"请求已受理但模型排队/卡住"的情况,阈值取实测 P99 再放宽,比如 30–60 秒。
② 空闲心跳超时——两个相邻 token 之间的最大间隔。这能抓住"已经开始输出但中途挂住"的情况,阈值可以比首 token 短得多,比如 10–20 秒。
③ 总时长上限——整个响应的时间上限,防止"一直缓慢吐字"把任务无限拖下去。
任一触发就主动中断连接(不要等对端),记录现场(已收到的部分内容、耗时、阶段),然后按策略恢复:同一通道重试 → 换通道重试 → 降级到非流式 → 报错并把中间结果交出。
判断"慢"还是"死"的关键是心跳:只要还在稳定吐字就不算卡死,所以不能只看总时长。
长任务跑到一半崩了怎么办?
状态机至少要覆盖:排队、运行、等待人工确认、暂停、成功、失败、重试中、已取消——每个状态有明确的进入/退出条件与持久化内容。
检查点要落盘四样东西:任务目标与约束、已完成步骤清单、每步的产物指针、下一步计划。落盘时机选在"每个子目标完成之后"而不是固定时间间隔,因为子目标边界才是真正的可恢复点。
恢复流程是:读出检查点 → 校验产物是否已存在(避免重复执行)→ 从下一个未完成步骤继续,并把"之前发生过什么"用摘要注入上下文。
还有一个容易被忽略的点:重试不能重复副作用,所以恢复时必须先查状态再决定是否执行,而不是无条件重放。
你的系统怎么区分"工具超时了"和"工具执行失败了"?
执行失败意味着服务明确返回了错误(4xx/5xx、业务异常),说明请求已被处理且结果是失败——通常不该原样重试,要么修参数,要么换方案。
超时意味着我们只是没等到回应,请求可能成功了、可能失败了、也可能还在执行中——这叫"结果未知",比失败更危险,直接重试可能造成重复副作用。
所以正确做法是:对可能产生副作用的工具,超时后先走状态查询(用幂等键或业务 ID 查这次调用到底有没有生效),确认未生效才重试;如果工具没有查询接口,就不该给它自动重试的权限,只能上报人工处理。
这也解释了为什么设计工具时就要把幂等键和查询能力作为契约的一部分,而不是事后补。
上下文窗口 128k、触发阈值 60%、单条工具结果上限取窗口的 10%。某工具一次返回约 4 万 token 的结果,压缩流程会先对它做什么? 高频
裁剪方式是头尾保留:留头部 60%、尾部 30%,中间约 10% 替换成「此处省略 N 字符」的显式标注。
判据:先裁单条、再摘要整体,且省略量必须显式写出——否则模型会以为看到的就是全部内容。
追问链
- 为什么尾部只留 30% 而头部留 60%?
- 省略标注为什么不能省掉?
上下文自动压缩能救回窗口,但它会改前缀——和 KV Cache 的命中率怎么权衡?
解法不是二选一,而是把压缩做成「少改头部、多动尾部」:不可压缩区(原始目标、用户约束、已确认结论)放在最前且只追加,压缩只作用于后面的过程性内容——这样头部前缀始终稳定,保住的是最贵的那一段。
判据:阈值要留足余量(本章实现里到 60% 就触发),一次压到位;比起窗口满到被迫压缩甚至报错,多付一次 prefill 的钱是划算的。
追问链
- 为什么头部比尾部更值得保?
- 如果把动态内容都放到头部会怎样?
为什么要把所有模型调用收敛到一个 provider 适配层?
判据:模型名、端点、能力差异都收口在适配层,业务代码不感知 provider。
追问链
- 不支持工具调用的模型怎么降级?
- 能力探测具体探测什么?
结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。