Published on

Agent Harness 工程:上下文压缩

Authors
  • avatar
    Name
    XiaoLeiJun
    Twitter

Agent Harness 工程:上下文压缩

上一篇我们把 Skill 拆成了一个按需加载机制:启动时只把 Skill 的 namedescription 放进系统提示词,等任务真的匹配某个 Skill 时,再调用 load_skill 加载完整 SKILL.md

这其实已经是在做一种上下文管理:不需要的内容,不要提前塞进上下文。

但 Agent 跑起来之后,还有一个更直接的问题:messages 会不断膨胀。

回顾一下前几篇的 Agent Loop:

  1. 用户发来任务;
  2. 模型返回工具调用;
  3. Harness 执行工具;
  4. 工具结果写回 messages
  5. 模型基于新的 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_callstool 结果的配对关系。压缩时如果只删一半,模型接口可能直接报错。

第五,压缩本身也有成本。 调 LLM 做摘要会消耗 token 和时间。压缩太频繁,Agent 会变慢;压缩太晚,又可能已经超窗。

所以工程上通常不会只靠一种办法,而是分层处理:工具先限制输出,旧结果再 artifact 化,旧消息做摘要,最近几轮完整保留,最后再准备兜底压缩。

第一层:限制工具结果

最容易撑爆上下文的是工具结果。

尤其是 read_filebash、搜索类工具、测试命令。它们很容易一次返回几千行内容。

前几篇里,我们已经在一些工具里做了简单截断,比如 read_file 默认只读前 200 行。这是上下文压缩的第一层:工具返回时就不要返回无限大内容。

可以把 read_file 改成支持 start_linelimit

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_SUMMARYTOOL_ARTIFACTSCONTEXT_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}

这段代码做了三件事:

  1. 如果工具结果很短,就不动;
  2. 如果工具结果很长,就把完整内容放进 state.tool_artifacts
  3. messages 里只保留 artifact_id、长度、hash、开头和结尾。

这里的 tool_artifacts 只是教学版内存存储。真实系统里可以把原始工具结果放到本地文件、对象存储、数据库或 run artifact 里。

注意,这不是语义摘要,只是安全截断。它能防止上下文爆炸,但不保证保留所有关键含义。

语义摘要要到后面一层再做。

第三层:保留最近消息,摘要旧消息

对长期任务来说,最常用的压缩方式是:

  1. 最近几轮完整保留;
  2. 更早的消息合并成摘要;
  3. 摘要作为新的上下文状态放回模型输入。

先写一个粗略 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 风格的函数。

它做三件事:

  1. 先压缩过大的工具结果;
  2. 如果仍然超过预算,就把旧消息摘要掉;
  3. 最后只把近期消息和摘要一起放进模型输入。
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 的工具调用消息有配对关系:

  1. assistant 消息里带 tool_calls
  2. 后面必须有对应 tool_call_idtool 消息。

如果压缩时只删掉其中一半,就可能破坏消息协议。

比如保留了一个 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 返回后检查。 子代理结果本来就应该是压缩结果,但如果仍然很长,也可以继续摘要。

异常兜底。 如果模型接口返回上下文超限错误,就触发更激进的压缩,再重试一次。

教学版可以先用“每轮调用前检查 + 异常兜底”。

兜底压缩

即使前面做了很多限制,也可能遇到上下文仍然超限的情况。

比如用户一次贴了超长文本,或者多个工具连续返回大量内容。

这时需要一个兜底策略:

  1. 保留系统提示词;
  2. 保留当前用户最新请求;
  3. 保留当前 Todo;
  4. 保留压缩摘要;
  5. 保留最近几条消息;
  6. 其它全部摘要或丢弃。

可以写一个更激进的 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 增加了上下文压缩层:

  1. 区分上下文、上下文窗口、记忆和 messages
  2. 在工具返回时限制输出长度;
  3. 对旧的大工具结果做替换或 artifact 化;
  4. 最近消息完整保留,旧消息用 LLM 做语义摘要;
  5. 在模型调用前通过 build_model_messages() 统一整理上下文;
  6. 注意工具调用消息必须成组保留或成组摘要;
  7. 准备一个更激进的兜底压缩策略;
  8. 接受压缩会丢细节,并用日志、artifact、Todo 和摘要降低风险。

到这里,Agent Harness 已经开始有了长期运行的基础能力:不是只会一轮一轮地堆 messages,而是能主动整理自己的工作上下文。

下一章我们就把“记忆”单独展开:哪些信息应该进入长期记忆,哪些只应该留在当前任务里,Memory Store 应该怎么设计,又如何避免把错误事实永久保存下来。