- Published on
Agent Harness 工程:上下文压缩
- Authors

- Name
- XiaoLeiJun
Agent Harness 工程:上下文压缩
上一篇我们把 Skill 拆成了一个按需加载机制:启动时只把 Skill 的 name 和 description 放进系统提示词,等任务真的匹配某个 Skill 时,再调用 load_skill 加载完整 SKILL.md。
这其实已经是在做一种上下文管理:不需要的内容,不要提前塞进上下文。
但 Agent 跑起来之后,还有一个更直接的问题:messages 会不断膨胀。
回顾一下前几篇的 Agent Loop:
- 用户发来任务;
- 模型返回工具调用;
- Harness 执行工具;
- 工具结果写回
messages; - 模型基于新的
messages继续下一轮。
这个循环越跑越久,messages 里就会积累越来越多内容:
- 用户需求;
- 模型中间回复;
- 工具调用参数;
- 工具执行结果;
- TodoWrite 更新;
- Skill 正文;
- Subagent 返回结果;
- 权限拒绝原因;
- 测试日志和错误栈;
- 大段文件内容。
而模型的上下文窗口是有限的。超过窗口之后,轻则模型注意力下降,开始忘记关键约束;重则请求直接失败。
所以这一篇继续给 Harness 加一个能力:上下文压缩。
先分清几个概念
在写代码之前,先把几个词分清楚。
上下文
上下文是模型在某一次调用里能看到的全部信息。
对我们的 Harness 来说,它通常包括:
- 系统提示词;
- 当前用户任务;
messages里的历史对话;- 工具调用结果;
- 当前 Todo;
- 已加载的 Skill;
- 压缩后的历史摘要;
- 其它被注入本次请求的状态。
上下文是“这次调用能看见什么”。它不是一个固定文件,也不是一个永久存储,而是 Harness 在每次调用模型之前临时组装出来的一份输入。
比如同一个任务里,第一轮上下文可能只有用户需求和系统提示词;第五轮上下文里可能多了 Todo、工具结果、Skill 正文;第十轮上下文里可能又把早期工具结果压缩成摘要。
上下文窗口
上下文窗口是模型一次调用能接收的最大 token 数。
不同模型窗口大小不同。窗口越大,可以塞进去的内容越多,但这不意味着应该无脑塞。内容越杂,模型越难抓住重点;内容越长,成本也越高。
可以把上下文窗口理解成“这次模型调用的容量上限”。它只负责限制你最多能放多少东西,不会自动判断哪些信息重要。
记忆
记忆是跨轮次、跨任务、甚至跨会话保存下来的稳定信息。
比如:
- 用户偏好的代码风格;
- 项目的固定约束;
- 某个长期任务的状态;
- 已经确认过的事实;
- 之前总结出的经验。
记忆不等于当前上下文。记忆可以被读取后放进上下文,但它本身更像一个外部状态库。
比如用户长期偏好“回答用中文”,这适合进入记忆;但某一次 pytest 的完整失败日志,只适合留在当前任务上下文里,甚至过几轮后就应该被摘要或 artifact 化。
messages 是什么
messages 是当前对话和工具执行过程的流水账。
它是上下文的一部分,但不是全部上下文;它也不是长期记忆。很多 messages 里的内容只在当时有用,比如一次 read_file 的完整输出、一次失败命令的完整日志。
这就是为什么我们需要压缩:不是所有进入过 messages 的内容,都值得永远留在上下文里。
messages 是上下文的来源之一;记忆在上下文外部,只有被检索出来后才进入本次模型输入。
可以用一句话区分这几个概念:
| 概念 | 它是什么 | 生命周期 |
|---|---|---|
| 上下文 | 本次模型调用实际看到的输入 | 每次调用前重新组装 |
| 上下文窗口 | 模型允许输入的容量上限 | 由模型决定 |
| messages | 对话和工具调用过程的流水账 | 当前任务内持续增长 |
| 记忆 | 跨任务保存的稳定事实或偏好 | 长期存在,按需放进上下文 |
上下文压缩要解决什么
上下文压缩不是简单地“删掉前面的消息”。
真正要做的是在有限窗口里保留最有价值的信息:
- 当前用户目标;
- 仍然有效的约束;
- 当前 Todo;
- 已经做过的重要决策;
- 修改过哪些文件;
- 工具调用得到的关键结论;
- 失败过的路径和原因;
- 最近几轮对话和工具结果。
同时丢掉或压缩低价值信息:
- 很久以前的闲聊;
- 已经用完的大段文件内容;
- 重复的日志;
- 已经总结过的工具输出;
- 不再相关的中间尝试;
- 过长但只需要结论的 Subagent 结果。
上下文压缩不是让模型忘掉一切,而是把流水账整理成“摘要 + 近期细节 + 当前状态”。
上下文压缩难在哪里
上下文压缩听起来像是在做“长度优化”,但真正难的是判断信息价值。
第一,旧信息不一定不重要。 很久以前用户说过的一句“不要改 public API”,可能比最近一轮工具输出更重要。如果只按时间滑动窗口,很容易把关键约束丢掉。
第二,长内容不一定没价值。 大文件、大日志、大 diff 里可能只有一小段关键线索。直接截断会省 token,但也可能刚好截掉根因。
第三,摘要会损失细节。 LLM 摘要不是无损压缩。它可能漏掉边界条件,也可能把不确定的观察写得太确定。所以摘要里最好保留文件名、行号、命令、错误码、决策原因。
第四,消息协议不能被破坏。 工具调用有 assistant.tool_calls 和 tool 结果的配对关系。压缩时如果只删一半,模型接口可能直接报错。
第五,压缩本身也有成本。 调 LLM 做摘要会消耗 token 和时间。压缩太频繁,Agent 会变慢;压缩太晚,又可能已经超窗。
所以工程上通常不会只靠一种办法,而是分层处理:工具先限制输出,旧结果再 artifact 化,旧消息做摘要,最近几轮完整保留,最后再准备兜底压缩。
第一层:限制工具结果
最容易撑爆上下文的是工具结果。
尤其是 read_file、bash、搜索类工具、测试命令。它们很容易一次返回几千行内容。
前几篇里,我们已经在一些工具里做了简单截断,比如 read_file 默认只读前 200 行。这是上下文压缩的第一层:工具返回时就不要返回无限大内容。
可以把 read_file 改成支持 start_line 和 limit:
def read_file(path: str, start_line: int = 1, limit: int = 200) -> str:
try:
target = safe_path(path)
if not target.exists():
return f"Error: file does not exist - {path}"
if not target.is_file():
return f"Error: path is not a file - {path}"
lines = target.read_text(encoding="utf-8").splitlines()
start = max(1, start_line)
end = min(len(lines), start + limit - 1)
selected = lines[start - 1 : end]
rendered = [f"{line_no}: {line}" for line_no, line in enumerate(selected, start)]
if end < len(lines):
rendered.append(f"... ({len(lines) - end} more lines)")
return "\n".join(rendered)[:50000]
except Exception as exc:
return f"Error: {exc}"
这样模型不需要一次读完整文件。
它可以先读目录,再读文件开头,再根据需要读某个范围。对大文件来说,这比“一口气把全部内容塞进 messages”稳定得多。
同样,bash 也应该限制输出长度:
MAX_TOOL_OUTPUT_CHARS = 50000
def trim_tool_output(output: str) -> str:
if len(output) <= MAX_TOOL_OUTPUT_CHARS:
return output
head = output[:20000]
tail = output[-8000:]
omitted = len(output) - len(head) - len(tail)
return f"""
{head}
... ({omitted} characters omitted) ...
{tail}
""".strip()
这个策略很朴素,但非常有效。
它的原则是:工具函数本身就应该对返回值负责,不要把“无限输出”直接交给 Agent Loop。
第二层:替换已经用过的大工具结果
工具刚返回时,模型确实可能需要看完整结果。
但过了几轮之后,完整结果往往就没那么重要了。比如模型读过一个 500 行文件,接下来真正需要记住的可能只是:
- 这个文件定义了哪个函数;
- 关键配置在哪里;
- 哪几行需要修改;
- 是否发现了某个错误。
所以可以对旧的 tool 消息做压缩,把大段原始结果替换成摘要或占位符。
为了让代码更好理解,先定义一个 ContextState,把压缩相关的状态集中放在一起:
import hashlib
from dataclasses import dataclass, field
from typing import Any
@dataclass
class ContextState:
summary: str = ""
tool_artifacts: dict[str, str] = field(default_factory=dict)
token_budget: int = 24000
recent_messages_to_keep: int = 12
这比把 CONTEXT_SUMMARY、TOOL_ARTIFACTS、CONTEXT_TOKEN_BUDGET 都散落成全局变量更清楚。
对一个任务来说,ContextState 就是 Harness 的上下文工作台:
| 字段 | 作用 |
|---|---|
summary | 被压缩掉的旧历史摘要 |
tool_artifacts | 被移出上下文的大工具结果 |
token_budget | 本次模型输入的预算 |
recent_messages_to_keep | 最近完整保留多少条消息 |
然后写一个 deterministic 版本的工具结果压缩,不调用 LLM:
def compact_tool_message(
message: dict[str, Any],
state: ContextState,
limit: int = 3000,
) -> dict[str, Any]:
if message.get("role") != "tool":
return message
content = str(message.get("content") or "")
if len(content) <= limit:
return message
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
artifact_id = f"tool-result-{digest}"
state.tool_artifacts[artifact_id] = content
compacted = f"""
[large tool result compacted]
artifact_id: {artifact_id}
original_chars: {len(content)}
sha256: {digest}
head:
{content[:1200]}
tail:
{content[-800:]}
""".strip()
return {**message, "content": compacted}
这段代码做了三件事:
- 如果工具结果很短,就不动;
- 如果工具结果很长,就把完整内容放进
state.tool_artifacts; messages里只保留artifact_id、长度、hash、开头和结尾。
这里的 tool_artifacts 只是教学版内存存储。真实系统里可以把原始工具结果放到本地文件、对象存储、数据库或 run artifact 里。
注意,这不是语义摘要,只是安全截断。它能防止上下文爆炸,但不保证保留所有关键含义。
语义摘要要到后面一层再做。
第三层:保留最近消息,摘要旧消息
对长期任务来说,最常用的压缩方式是:
- 最近几轮完整保留;
- 更早的消息合并成摘要;
- 摘要作为新的上下文状态放回模型输入。
先写一个粗略 token 估算函数:
def estimate_tokens(text: str) -> int:
return max(1, len(text) // 4)
def message_to_text(message: dict[str, Any]) -> str:
parts = [
f"role: {message.get('role')}",
f"content: {message.get('content') or ''}",
]
if message.get("tool_calls"):
parts.append(f"tool_calls: {message['tool_calls']}")
return "\n".join(parts)
def estimate_messages_tokens(messages: list[dict[str, Any]]) -> int:
return sum(estimate_tokens(message_to_text(message)) for message in messages)
这里的估算不精确,但教学版够用。
生产系统最好用模型对应的 tokenizer,避免估算偏差太大。
接着定义压缩预算:
context_state = ContextState(
token_budget=24000,
recent_messages_to_keep=12,
)
token_budget 不应该等于模型最大窗口。
还要给模型输出、工具 schema、系统提示词、Skill 正文、临时插入的状态留空间。所以真实项目里通常会设置一个更保守的预算。
然后写摘要函数。
这里可以借助 LLM 对旧消息做语义摘要:
SUMMARY_PROMPT = """
你负责压缩 Agent Harness 的历史上下文。
请保留:
1. 用户当前目标和明确要求;
2. 已经完成的重要步骤;
3. 当前仍然有效的约束;
4. 修改过、读取过、需要继续关注的文件;
5. 工具调用得到的关键结论;
6. 失败过的命令、错误原因和已排除路径;
7. 当前 Todo 状态和下一步建议。
请丢弃:
1. 大段原始工具输出;
2. 重复日志;
3. 已经失效的中间尝试;
4. 和当前任务无关的细节。
"""
def update_context_summary(
state: ContextState,
old_messages: list[dict[str, Any]],
) -> None:
payload = {
"existing_summary": state.summary,
"old_messages": old_messages,
}
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": SUMMARY_PROMPT},
{
"role": "user",
"content": json.dumps(payload, ensure_ascii=False, indent=2),
},
],
)
summary = response.choices[0].message.content or ""
state.summary = summary.strip()[:8000]
这里把旧摘要也一起传进去,是为了做增量摘要。
否则每次压缩都会丢掉更早历史。
第四层:把压缩接进模型调用
现在写一个 ContextManager 风格的函数。
它做三件事:
- 先压缩过大的工具结果;
- 如果仍然超过预算,就把旧消息摘要掉;
- 最后只把近期消息和摘要一起放进模型输入。
def split_for_compaction(
messages: list[dict[str, Any]],
keep_recent: int,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
if len(messages) <= keep_recent:
return [], messages
cutoff = len(messages) - keep_recent
return messages[:cutoff], messages[cutoff:]
def compact_messages_if_needed(
messages: list[dict[str, Any]],
state: ContextState,
) -> list[dict[str, Any]]:
compacted = [compact_tool_message(message, state) for message in messages]
if estimate_messages_tokens(compacted) <= state.token_budget:
return compacted
old_messages, recent_messages = split_for_compaction(
compacted,
keep_recent=state.recent_messages_to_keep,
)
if old_messages:
update_context_summary(state, old_messages)
return recent_messages
然后构造真正发给模型的消息:
def build_model_messages(
messages: list[dict[str, Any]],
state: ContextState,
) -> list[dict[str, Any]]:
working_messages = compact_messages_if_needed(messages, state)
model_messages = [{"role": "system", "content": SYSTEM_PROMPT}]
if state.summary:
model_messages.append(
{
"role": "system",
"content": f"下面是被压缩的历史上下文摘要:\n{state.summary}",
}
)
model_messages.extend(working_messages)
return model_messages
最后在 run_loop() 里替换模型调用部分:
def run_loop(user_message: str, max_steps: int = 8) -> str:
messages: list[dict[str, Any]] = [{"role": "user", "content": user_message}]
state = ContextState(token_budget=24000, recent_messages_to_keep=12)
for step in range(max_steps):
response = client.chat.completions.create(
model=MODEL,
messages=build_model_messages(messages, state),
tools=TOOLS,
)
# 后面的工具调用处理逻辑继续沿用前几篇
这就是上下文压缩接入 Harness 的最小形态。
Agent Loop 仍然负责循环,工具系统仍然负责动作,Context Manager 负责在每次模型调用前整理输入。
上下文不是一整坨 messages,而是由稳定提示词、任务状态、摘要、近期消息和外部 artifacts 共同组成。
关于工具调用配对的一个坑
这里有一个很容易踩的坑。
Chat Completions 的工具调用消息有配对关系:
assistant消息里带tool_calls;- 后面必须有对应
tool_call_id的tool消息。
如果压缩时只删掉其中一半,就可能破坏消息协议。
比如保留了一个 tool 消息,却删掉了前面的 assistant.tool_calls,模型接口可能直接报错。反过来,只保留 assistant.tool_calls,却删掉对应 tool 消息,也会出问题。
所以真实工程里不要简单按消息条数硬切。
更稳的做法是把消息切成 turn:
- 普通用户消息是一个 turn;
- 普通 assistant 回复是一个 turn;
- assistant tool_calls 加上后续对应 tool 结果是一组 turn;
- 压缩时按 turn 整组保留或整组摘要。
上面的教学代码为了简单,只展示了核心思路。生产版一定要按消息协议做更严格的切分。
读大文件怎么办
草稿里提到一个很实际的问题:一次读了太多文件,直接把上下文窗口打满怎么办?
最好的方式不是“读完再压缩”,而是避免一开始就读太多。
可以给 Agent 几个更细的工具习惯:
先搜索,再读取。 比如先用 rg 找关键词、函数名、文件路径,再读取相关片段。
按范围读取。 read_file(path, start_line, limit) 比一次读全文件更稳。
工具结果带行号。 这样模型后续可以引用具体位置,也可以继续读取附近上下文。
必要时让 Subagent 调查。 大范围探索可以交给只读 Subagent,主 Agent 只接收结论。
把原始输出放 artifact。 主上下文里保留摘要和 artifact id,需要时再按范围重新读取。
这和 Skill 的按需加载是同一个原则:不要提前加载无关内容,也不要一次加载过多内容。
什么时候做压缩
压缩有几种触发时机。
每轮调用前检查。 这是最稳的方式。只要准备调用模型,就先估算上下文是否超预算。
工具结果写入后检查。 如果某次工具结果特别大,可以立刻压缩或转成 artifact。
Todo 完成一个阶段后检查。 比如一个 Todo 从 in_progress 变成 completed,可以把这一阶段的细节总结掉。
Subagent 返回后检查。 子代理结果本来就应该是压缩结果,但如果仍然很长,也可以继续摘要。
异常兜底。 如果模型接口返回上下文超限错误,就触发更激进的压缩,再重试一次。
教学版可以先用“每轮调用前检查 + 异常兜底”。
兜底压缩
即使前面做了很多限制,也可能遇到上下文仍然超限的情况。
比如用户一次贴了超长文本,或者多个工具连续返回大量内容。
这时需要一个兜底策略:
- 保留系统提示词;
- 保留当前用户最新请求;
- 保留当前 Todo;
- 保留压缩摘要;
- 保留最近几条消息;
- 其它全部摘要或丢弃。
可以写一个更激进的 fallback:
def hard_compact_messages(
messages: list[dict[str, Any]],
state: ContextState,
) -> list[dict[str, Any]]:
if not messages:
return []
old_messages, recent_messages = split_for_compaction(messages, keep_recent=6)
if old_messages:
update_context_summary(state, old_messages)
latest_user = next(
(message for message in reversed(messages) if message.get("role") == "user"),
None,
)
if latest_user and latest_user not in recent_messages:
return [latest_user] + recent_messages
return recent_messages
这会丢细节,但能让任务继续跑。
上下文压缩永远有取舍:压缩得越狠,越省 token,但越容易丢信息。
所以更好的做法是尽量在前面几层就控制住,而不是每次都靠兜底。
压缩一定会丢失信息
这是上下文压缩必须面对的事实。
无论是截断、摘要、替换、滑动窗口,都会丢掉一些细节。
所以不要把上下文压缩理解成“无损压缩”。它更像是做笔记:
- 原始工具输出是现场录音;
- 摘要是会议纪要;
- Todo 是行动清单;
- Skill 是做事方法;
- Memory 是长期档案。
会议纪要能帮助你继续工作,但它不可能保留现场录音的全部细节。
工程上要做的是让丢失尽量可控:
- 重要事实写进摘要;
- 关键文件和行号保留;
- 原始大结果放 artifact;
- 当前 Todo 不压掉;
- 最近几轮完整保留;
- 压缩发生时留下日志。
小结
这一篇给 Agent Harness 增加了上下文压缩层:
- 区分上下文、上下文窗口、记忆和
messages; - 在工具返回时限制输出长度;
- 对旧的大工具结果做替换或 artifact 化;
- 最近消息完整保留,旧消息用 LLM 做语义摘要;
- 在模型调用前通过
build_model_messages()统一整理上下文; - 注意工具调用消息必须成组保留或成组摘要;
- 准备一个更激进的兜底压缩策略;
- 接受压缩会丢细节,并用日志、artifact、Todo 和摘要降低风险。
到这里,Agent Harness 已经开始有了长期运行的基础能力:不是只会一轮一轮地堆 messages,而是能主动整理自己的工作上下文。
下一章我们就把“记忆”单独展开:哪些信息应该进入长期记忆,哪些只应该留在当前任务里,Memory Store 应该怎么设计,又如何避免把错误事实永久保存下来。