跑着跑着模型不吐字了、上下文满了、进程被用户关掉了、同一个工具超时了三次——这些才是 Harness 工程师每天面对的东西。它们有个共同特点:都不是「模型不够聪明」,而是「系统没设计好」。这一章处理的就是这类问题,也是「工程问题」与「研究问题」的分水岭。
编辑部注 · 本章是全站最贴近真实面试考点的一章。如果你只能读完一章然后去面试,读这一章。
先建立一个分类标准。当一个 Agent 出问题时,你要能在三秒内判断它属于哪一类:
| 类别 | 典型问题 | 该怎么解 | 归属 |
|---|---|---|---|
| 工程问题 | 流式响应卡死、上下文窗口超限、进程中断后无法恢复、工具超时后状态不一致 | 检测 + 状态机 + 检查点 + 幂等,全是确定性的系统设计 | Harness |
| 产品问题 | 用户不知道该问什么、产物格式不满足要求、权限申请流程太长 | 交互设计、默认值、引导 | 产品 |
| 研究问题 | 模型推理能力不足、幻觉、长链任务中目标漂移、指令跟随不稳 | 训练、数据、结构改进 | 模型团队 |
有位候选人面试某大模型公司的 Harness 岗,一面通过,二面落选。原因是他被问「做 Agent 遇到过哪些工程挑战」时,答的是 long-horizon 能力不足和减少幻觉。面试官的评价是:「这些都是研究问题,不是工程问题。」
他后来才想明白对方想听的是什么:换 provider 的成本与兼容、模型输出卡死后怎么自动恢复、上下文窗口满了怎么自动压缩。
这三个答案有共同特征——它们都是「不管模型多聪明都会发生,而且只能靠系统设计解决」的问题。答研究问题不会让你显得不懂技术,但会让你显得没在真实的系统里干过活。
一、流式卡死:最常见的线上事故
先说现象。流式响应可能以多种方式「卡住」:完全没有数据返回;返回了几个 token 后停住;返回了内容但没有结束标记;连接还活着但服务端不吐字。
这些现象背后是不同的原因,但处理策略可以统一。 关键在于:你的 Harness 必须有一套与「模型是否合作」无关的检测与恢复机制。
二、上下文自动压缩:让长任务能跑完
第 4 章讲了压缩策略,这里讲怎么把它做成一个自动机制,因为手动触发在长任务里不现实。
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 + 步骤序号」生成,恢复后重复执行会被识别并跳过 |
| 恢复语义 | 定义「从哪继续」 | 只重放未完成的步骤;已完成步骤的结果从快照读取,不重新执行 |
| 副作用日志 | 记录每个已发生的副作用 | 恢复时用它判断「这个操作到底做没做」,避免重复下单/重复发送 |
| 版本校验 | 恢复前检查代码版本与工具集是否变更 | 工具签名变了就不该静默续跑,应提示用户或重新规划 |
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、尾随逗号、单引号、注释。解析器要能容忍这些常见变体,而不是直接抛异常。宽容解析 + 严格校验的组合,比严格解析 + 宽容校验有效得多。
第三,时间与预算的耦合。 用户的等待耐心和你的预算上限不是同一件事。建议分别设:软超时(触发降级策略,比如缩小输出规模、跳过可选步骤)和硬超时(直接返回部分结果)。只有硬超时会导致任务失败。
五、自测
六、小结
| 问题 | 工程解法 | 关键细节 |
|---|---|---|
| 流式卡死 | TTFB 超时 + 字节间超时 + 总超时,三级独立 | 只设总超时无法检测「缓慢但持续」的卡死 |
| 输出不完整 | 状态机 + 有限重试 + 退避 | 已发生副作用时不能整段重试 |
| 上下文超限 | 自动压缩:单条裁剪 → 旧步骤摘要 → 结论钉住 | 上下文要分层,不可压缩区放最前 |
| 进程中断 | 检查点 + 幂等键 + 副作用日志 | 幂等键必须稳定(基于任务 ID + 步骤序号) |
| provider 变更 | 适配层 + 能力探测 | 不要硬编码模型名 |
| 解析失败 | 宽容解析 + 严格校验 | 兼容代码块包裹、尾随逗号等常见变体 |
「换 provider、卡死恢复、上下文压缩」这三个答案之所以被面试官认可,是因为它们都满足同一个条件:不管模型多聪明,这些问题都会发生,而且只能靠系统设计解决。本刊编辑部
Harness 工程部分到此结束。接下来进入一个更少人认真做、也更能体现系统思维的部分:怎么证明你的改动真的让系统变好了。
◇ 面试官会怎么问
共 4 条 · 其中 2 条高频 · 先自己答一遍,再展开对照
你做 Agent 的时候遇到过哪些工程挑战? 高频
① provider 侧不稳定——限流、超时、通道抖动,需要多通道抽象 + 健康探测 + 自动切换 + 切换后的结果一致性校验;
② 流式输出卡死——连接还在但不出 token,需要首 token 超时 + 空闲心跳超时 + 强制中断与重试;
③ 上下文溢出——主动计量、分级压缩、状态外置,并在触发时给用户可见提示;
④ 长任务中断恢复——进程被杀、机器重启,需要任务状态机 + 检查点 + 断点续跑;
⑤ 副作用重复——重试导致重复写库,需要幂等键与去重表;
⑥ 成本失控——预算上限、轮数上限、缓存友好前缀;
⑦ 变更风险——prompt 一改就翻车,需要版本化、灰度、自动回滚。
答完再补一句:这些问题的共同点是"模型没坏,是系统没兜住",这正是 Harness 存在的价值。
追问链
- 流式卡死具体怎么检测?阈值怎么定?
- 上下文自动压缩在架构上放在哪一层?
模型输出卡死(连接还在但不出 token)怎么发现、怎么恢复? 高频
① 首 token 超时——从发起请求到收到第一个 token 的时间上限。这能抓住"请求已受理但模型排队/卡住"的情况,阈值取实测 P99 再放宽,比如 30–60 秒。
② 空闲心跳超时——两个相邻 token 之间的最大间隔。这能抓住"已经开始输出但中途挂住"的情况,阈值可以比首 token 短得多,比如 10–20 秒。
③ 总时长上限——整个响应的时间上限,防止"一直缓慢吐字"把任务无限拖下去。
任一触发就主动中断连接(不要等对端),记录现场(已收到的部分内容、耗时、阶段),然后按策略恢复:同一通道重试 → 换通道重试 → 降级到非流式 → 报错并把中间结果交出。
判断"慢"还是"死"的关键是心跳:只要还在稳定吐字就不算卡死,所以不能只看总时长。
长任务跑到一半崩了怎么办?
状态机至少要覆盖:排队、运行、等待人工确认、暂停、成功、失败、重试中、已取消——每个状态有明确的进入/退出条件与持久化内容。
检查点要落盘四样东西:任务目标与约束、已完成步骤清单、每步的产物指针、下一步计划。落盘时机选在"每个子目标完成之后"而不是固定时间间隔,因为子目标边界才是真正的可恢复点。
恢复流程是:读出检查点 → 校验产物是否已存在(避免重复执行)→ 从下一个未完成步骤继续,并把"之前发生过什么"用摘要注入上下文。
还有一个容易被忽略的点:重试不能重复副作用,所以恢复时必须先查状态再决定是否执行,而不是无条件重放。
你的系统怎么区分"工具超时了"和"工具执行失败了"?
执行失败意味着服务明确返回了错误(4xx/5xx、业务异常),说明请求已被处理且结果是失败——通常不该原样重试,要么修参数,要么换方案。
超时意味着我们只是没等到回应,请求可能成功了、可能失败了、也可能还在执行中——这叫"结果未知",比失败更危险,直接重试可能造成重复副作用。
所以正确做法是:对可能产生副作用的工具,超时后先走状态查询(用幂等键或业务 ID 查这次调用到底有没有生效),确认未生效才重试;如果工具没有查询接口,就不该给它自动重试的权限,只能上报人工处理。
这也解释了为什么设计工具时就要把幂等键和查询能力作为契约的一部分,而不是事后补。
结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。