Agent Harness 工程:上下文压缩
Agent Harness 工程:上下文压缩
上一篇我们把 Skill 拆成了一个按需加载机制:启动时只把 Skill 的 name 和 description 放进系统提示词,等任务真的匹配某个 Skill 时,再调用 load_skill 加载完整 SKILL.md。
这其实已经是在做一种上下文管理:不需要的内容,不要提前塞进上下文。
但 Agent 跑起来之后,还有一个更直接的问题:messages 会不断膨胀。
回顾一下前几篇的 Agent Loop:
- 用户发来任务;
- 模型返回工具调用;
- Harness 执行工具;
- 工具结果写回
messages; - 模型基于新的
messages继续下一轮。
这个循环越跑越久,messages 里就会积累越来越多内容:
- 用户需求;
- 模型中间回复;
- 工具调用参数;
- 工具执行结果;
- TodoWrite 更新;
- Skill 正文;
- Subagent 返回结果;
- 权限拒绝原因;
- 测试日志和错误栈;
- 大段文件内容。
而模型的上下文窗口是有限的。超过窗口之后,轻则模型注意力下降,开始忘记关键约束;重则请求直接失败。
所以这一篇继续给 Harness 加一个能力:上下文压缩。
本章仍然沿用前八章的同一个 Python Harness。为了让示例集中在核心问题,后文只展开与上下文管理直接相关的结构;工具 Schema 和通用分发代码继续使用前文定义。各代码块共同组成同一套 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
import json
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_turns_to_keep: int = 6
这比把 CONTEXT_SUMMARY、TOOL_ARTIFACTS、CONTEXT_TOKEN_BUDGET 都散落成全局变量更清楚。
对一个任务来说,ContextState 就是 Harness 的上下文工作台:
| 字段 | 作用 |
|---|---|
summary | 被压缩掉的旧历史摘要 |
tool_artifacts | 被移出上下文的大工具结果 |
token_budget | 本次模型输入的预算 |
recent_turns_to_keep | 最近完整保留多少个协议组 |
然后写一个确定性的工具结果压缩函数,不调用 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 里。
只保存 artifact_id 还不够,Agent 必须能按范围重新读取。最小实现可以把下面的函数注册为只读工具,并由 Harness 注入当前 ContextState:
def read_tool_artifact(
state: ContextState,
artifact_id: str,
start: int = 0,
limit: int = 3000,
) -> str:
content = state.tool_artifacts.get(artifact_id)
if content is None:
return f"Error: artifact not found - {artifact_id}"
start = max(0, start)
limit = max(1, min(limit, 10000))
chunk = content[start : start + limit]
next_offset = start + len(chunk)
suffix = f"\n[next_offset={next_offset}]" if next_offset < len(content) else ""
return chunk + suffix
真实 Artifact Store 还要校验当前 run、Task 和 Agent 是否有权读取对应对象。artifact_id 是引用,不是授权凭证。
注意,这不是语义摘要,只是安全截断。它能防止上下文爆炸,但不保证保留所有关键含义。
语义摘要要到后面一层再做。
第三层:保留最近消息,摘要旧消息
对长期任务来说,最常用的压缩方式是:
- 最近几轮完整保留;
- 更早的消息合并成摘要;
- 摘要作为新的上下文状态放回模型输入。
先写一个粗略 token 估算函数:
def estimate_tokens(text: str) -> int:
ascii_chars = sum(1 for char in text if ord(char) < 128)
non_ascii_chars = len(text) - ascii_chars
return max(1, ascii_chars // 4 + non_ascii_chars)
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,但比统一使用 len(text) // 4 更保守:英文与代码粗略按 4 字符一个 token,中文等非 ASCII 字符按一个字符一个 token 预留空间。
生产系统最好用模型对应的 tokenizer,避免估算偏差太大。
接着定义压缩预算:
context_state = ContextState(
token_budget=24000,
recent_turns_to_keep=6,
)
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),
},
],
max_completion_tokens=2000,
)
choice = response.choices[0]
if choice.finish_reason == "length":
raise RuntimeError("context summary was truncated")
summary = (choice.message.content or "").strip()
if not summary:
raise RuntimeError("context summary is empty")
state.summary = summary[:8000]
这里把旧摘要也一起传进去,是为了做增量摘要。
否则每次压缩都会丢掉更早历史。
第四层:把压缩接进模型调用
现在写一个 ContextManager 风格的实现。
它做四件事:
- 先压缩过大的工具结果;
- 按消息协议把工具调用和结果组成不可拆分的组;
- 如果仍然超过预算,就把旧组摘要掉,并从当前历史中移除;
- 最后只把近期消息和摘要一起放进模型输入。
class MessageProtocolError(ValueError):
pass
def group_protocol_turns(
messages: list[dict[str, Any]],
) -> list[list[dict[str, Any]]]:
"""Keep an assistant tool call and all of its tool results together."""
groups: list[list[dict[str, Any]]] = []
index = 0
while index < len(messages):
message = messages[index]
tool_calls = message.get("tool_calls") or []
if message.get("role") == "assistant" and tool_calls:
expected_ids = [str(call.get("id") or "") for call in tool_calls]
if any(not item for item in expected_ids) or len(set(expected_ids)) != len(
expected_ids
):
raise MessageProtocolError("assistant tool calls need unique ids")
group = [message]
seen_ids: set[str] = set()
index += 1
while index < len(messages) and messages[index].get("role") == "tool":
tool_message = messages[index]
tool_call_id = str(tool_message.get("tool_call_id") or "")
if tool_call_id not in expected_ids or tool_call_id in seen_ids:
raise MessageProtocolError(
f"unexpected tool result: {tool_call_id or 'missing id'}"
)
group.append(tool_message)
seen_ids.add(tool_call_id)
index += 1
if seen_ids != set(expected_ids):
missing = sorted(set(expected_ids) - seen_ids)
raise MessageProtocolError(f"missing tool results: {missing}")
groups.append(group)
continue
if message.get("role") == "tool":
raise MessageProtocolError("tool result has no preceding assistant call")
groups.append([message])
index += 1
return groups
def split_for_compaction(
messages: list[dict[str, Any]],
keep_recent_turns: int,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
groups = group_protocol_turns(messages)
if len(groups) <= keep_recent_turns:
return [], messages
cutoff = len(groups) - keep_recent_turns
old_messages = [message for group in groups[:cutoff] for message in group]
recent_messages = [message for group in groups[cutoff:] for message in group]
return old_messages, recent_messages
def compact_messages_if_needed(
messages: list[dict[str, Any]],
state: ContextState,
) -> list[dict[str, Any]]:
# 结果压缩和历史裁剪都写回 canonical messages,避免下一轮重复处理旧历史。
messages[:] = [compact_tool_message(message, state) for message in messages]
summary_tokens = estimate_tokens(state.summary) if state.summary else 0
if estimate_messages_tokens(messages) + summary_tokens <= state.token_budget:
return messages
old_messages, recent_messages = split_for_compaction(
messages,
keep_recent_turns=state.recent_turns_to_keep,
)
if old_messages:
update_context_summary(state, old_messages)
messages[:] = recent_messages
return messages
这里必须原地更新 messages。如果只为当前请求返回一个裁剪副本,下一轮仍会再次看到完整旧历史,把同一批消息重复写进摘要;任务跑得越久,摘要反而越混乱。
再用一个很薄的对象统一暴露常规压缩、兜底压缩和当前摘要。后面的记忆、Prompt Assembler 与错误恢复都只依赖这个接口:
class ContextManager:
def __init__(self, state: ContextState):
self.state = state
@property
def summary(self) -> str:
return self.state.summary
def compact(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
return compact_messages_if_needed(messages, self.state)
def hard_compact(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
messages[:] = hard_compact_messages(messages, self.state)
return messages
然后构造真正发给模型的消息:
def build_model_messages(
messages: list[dict[str, Any]],
context_manager: ContextManager,
) -> list[dict[str, Any]]:
# compact() 可能刚刚更新 summary,所以必须先压缩、后读取摘要。
working_messages = context_manager.compact(messages)
model_messages = [{"role": "system", "content": SYSTEM_PROMPT}]
if context_manager.summary:
model_messages.append(
{
"role": "system",
"content": (
"下面是被压缩的历史上下文摘要:\n"
+ context_manager.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}]
context_manager = ContextManager(
ContextState(token_budget=24000, recent_turns_to_keep=6)
)
for step in range(max_steps):
response = client.chat.completions.create(
model=MODEL,
messages=build_model_messages(messages, context_manager),
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 整组保留或整组摘要。
上面的 group_protocol_turns() 已经实现了这个最小边界:它会校验调用 ID、拒绝孤立或重复的工具结果,并保证切分点不会落在工具调用组内部。流式响应还需要先完整聚合 tool_calls,通过 Schema 校验后再写入这里的 messages。
读大文件怎么办
还有一个很实际的问题:一次读取了太多文件,直接把上下文窗口打满怎么办?
最好的方式不是“读完再压缩”,而是避免一开始就读太多。
可以给 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 []
compacted = [compact_tool_message(message, state) for message in messages]
old_messages, recent_messages = split_for_compaction(
compacted,
keep_recent_turns=3,
)
if old_messages:
update_context_summary(state, old_messages)
latest_user = next(
(message for message in reversed(compacted) if message.get("role") == "user"),
None,
)
if latest_user and all(message is not latest_user for message 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()统一整理上下文; - 原地推进 canonical
messages,避免同一段历史被重复摘要; - 准备一个更激进的兜底压缩策略;
- 接受压缩会丢细节,并用日志、artifact、Todo 和摘要降低风险。
到这里,Agent Harness 已经开始有了长期运行的基础能力:不是只会一轮一轮地堆 messages,而是能主动整理自己的工作上下文。
下一章我们就把“记忆”单独展开:哪些信息应该进入长期记忆,哪些只应该留在当前任务里,Memory Store 应该怎么设计,又如何避免把错误事实永久保存下来。