上一篇我给 TypeScript Agent 加上了流式输出。

那一版已经能边生成边打印,也能处理流式工具调用里的参数碎片。体验好了很多,但 messages 还有一个老问题:它只会变长,不会变短。

每次用户输入,都会多一条 user 消息。模型回答,会多一条 assistant 消息。模型调用工具,还会多出 assistant.tool_callstool 结果。

短任务没事。长任务里,messages 很快会堆起来。每一轮请求又要把完整历史发给模型,input token 越来越多,速度和成本都会被拖住。再往后,就是 context window 不够用。

所以这一篇只做一件事:给 Agent 加 context compaction。

我现在的版本在 lv5-compact.ts

txt
用户输入
-> messages.push(user)
-> runStreamingAgent(messages)
-> Agent 追加 assistant / tool 消息
-> compactIfNeeded(messages)
-> 未超过阈值:原样继续
-> 超过阈值:旧历史压成摘要,替换 messages

先看问题在哪里#

在前几篇里,messages 是 Agent 的记忆。

最小 Agent Loop 靠它保存上下文:

txt
system
user
assistant
tool
assistant
user
assistant
...

流式版本里,我还特意补了一条完整的 assistantMessage。因为打印给用户看的 token,不等于保存给下一轮模型看的历史。

这就带来一个副作用:Agent 越能干,messages 增长越快。

普通聊天一轮通常增加两条消息:

txt
user -> assistant

带工具调用时,一轮可能是四条甚至更多:

txt
user
assistant: tool_calls
tool: result
assistant: final answer

如果让一个 coding agent 连续读文件、跑命令、改文件,消息数量涨得很快。早期历史里可能有用的信息,比如用户最初的目标、已经读过的文件、执行过的命令;也可能有很多已经不重要的细节。

我不想直接删掉旧消息。删太狠,模型会丢任务状态。

更合适的做法是:保留最近几轮,把更早的历史压成一段摘要。

压缩后的 messages 长什么样#

我希望压缩前后,Agent Loop 看到的仍然是普通 messages

也就是说,外层 loop 不需要知道“这里发生过压缩”。它只继续把 messages 传给模型。

压缩前大概是:

txt
system
user 1
assistant 1
user 2
assistant 2
...
user 10
assistant 10

压缩后变成:

txt
system
user: [上下文摘要 - 之前的对话记录]
assistant: 好的,我已了解之前的工作进展,继续。
user 8
assistant 8
user 9
assistant 9
user 10
assistant 10

这里有三块:

system 原样保留。它是 Agent 的基本行为约束。

摘要消息保存旧历史里的任务状态。

最近 N 条消息原样保留,因为当前对话附近的细节最容易影响下一步动作。

这样做有信息损失。摘要不可能等价于完整历史。但如果不压缩,长任务迟早会被 token 成本和上下文上限卡住。这里要接受一个取舍:旧细节变粗,当前状态留下。

compactIfNeeded 的入口#

压缩逻辑放在 src/context/compact.ts

它的入口很小:

ts
export interface CompactOptions {
  threshold?: number
  keepLast?:  number
}
 
export async function compactIfNeeded(
  messages: Message[],
  client: OpenAI,
  model: string,
  options: CompactOptions = {}
): Promise<{ messages: Message[]; compacted: boolean }> {
  const threshold = options.threshold ?? 20
  const keepLast  = options.keepLast  ?? 6
 
  if (messages.length <= threshold) {
    return { messages, compacted: false }
  }
 
  // 后面才真的压缩
}

threshold 决定什么时候触发压缩。现在 demo 里用 20 条消息,主要是为了容易观察。

keepLast 决定最近多少条消息不动。现在保留 6 条,大概是最近三轮普通对话;如果中间有工具调用,实际覆盖的轮数会少一点。

这里先用消息数量做阈值,主要是为了让 demo 更容易看懂。更完整的版本应该按 token 数判断,比如接近模型上下文窗口的某个比例时再触发。

把历史拆成三段#

真正压缩前,先把消息拆开:

ts
const systemMsg  = messages[0]?.role === 'system' ? messages[0] : null
const rest       = systemMsg ? messages.slice(1) : messages
 
const toKeep     = rest.slice(-keepLast)
const toCompress = rest.slice(0, rest.length - keepLast)

这段代码做了一个很朴素的判断:

systemMsg 不能压。

toKeep 不压。

toCompress 交给模型总结。

我之前容易把“压缩上下文”想成一个很复杂的系统,真的写到这一步时发现,最小版本就是数组切片。麻烦的地方不在切片,而在摘要要保留什么。

对于 coding agent,摘要不能只写“用户让助手分析了项目”。这种摘要太空,下一轮模型没法继续干活。

它至少要记住这些东西:

txt
用户的最终目标
已读取过的文件路径
已创建或修改的文件路径和内容要点
已执行的命令和关键结果
当前进展和下一步计划

这些信息都和后续动作有关。文件路径丢了,模型可能重复读文件;命令结果丢了,它可能重复执行;当前进展丢了,它会从头开始猜。

让模型生成任务摘要#

toCompress 里的消息会先转成一段文本:

ts
const historyText = toCompress
  .map(m => {
    const role = m.role === 'assistant' ? 'AI' : m.role === 'user' ? '用户' : m.role
    const content = typeof m.content === 'string'
      ? m.content
      : JSON.stringify(m.content)
    return `【${role}】${content}`
  })
  .join('\n\n')

然后发起一次独立的模型请求:

ts
const summaryRes = await client.chat.completions.create({
  model,
  messages: [
    {
      role: 'user',
      content: `请把下面的对话历史压缩成简洁的任务状态摘要。必须保留:
1. 用户的最终目标
2. 已读取过的文件路径
3. 已创建/修改的文件路径和内容要点
4. 已执行的命令和关键结果
5. 当前进展和下一步计划
 
对话历史如下:
${historyText}
 
请用简洁的要点格式输出摘要,不要重复原始对话内容。`,
    },
  ],
})

这次请求不走主 Agent Loop,也不带工具。它只负责把旧历史写成任务摘要。

这里我没有给摘要单独换模型。demo 里复用同一个 model,代码少一点。实际用的时候,可以把摘要交给更便宜的模型,只要它能稳定保留任务状态。

摘要回来以后,取出文本:

ts
const summary = summaryRes.choices[0]?.message?.content ?? '(摘要生成失败)'

这里还有一个可以继续改的点:如果摘要生成失败,现在只是塞一段“摘要生成失败”。更稳的做法是压缩失败就返回原 messages,并打印错误,让主对话不要因为压缩挂掉。

摘要怎么放回 messages#

压缩不是在原数组上删删改改。

我最后重新拼一个新的 messages

ts
const summaryMessages: Message[] = [
  {
    role: 'user',
    content: `[上下文摘要 - 之前的对话记录]\n${summary}`,
  },
  {
    role: 'assistant',
    content: '好的,我已了解之前的工作进展,继续。',
  },
]
 
const compacted: Message[] = [
  ...(systemMsg ? [systemMsg] : []),
  ...summaryMessages,
  ...toKeep,
]

我这里把摘要做成了一组 user -> assistant 消息。

user 消息里放“之前的对话记录摘要”,assistant 回复“已了解”。下一轮模型看到时,会更像是自然对话历史的一部分。

压缩后的结构仍然是 Chat Completions 能接受的 messages,不需要给 Agent Loop 增加新的 message 类型。

压缩放在什么时候#

这版没有在 Agent Loop 中间压缩。

压缩发生在一轮用户请求完成之后:

ts
messages.push({ role: 'user', content: text })
 
process.stdout.write('AI: ')
await runStreamingAgent(messages)
 
const result = await compactIfNeeded(messages, client, model, {
  threshold: 20,
  keepLast:  6,
})
 
if (result.compacted) {
  messages = result.messages
  console.log(`压缩后 messages 数量: ${messages.length}\n`)
}

这个位置比较干净。

runStreamingAgent 仍然只负责一件事:让模型回答,或者让模型调用工具再回答。

等这一轮结束,REPL 再检查 messages 是否需要压缩。压缩完以后,下一次用户输入就基于新的 messages 继续。

我不想在 tool call 中间动 messages。那时候 assistant 的工具调用、tool result、最终回答还没形成完整闭环。中途压缩,容易把一组相关消息切散。

替换整个数组#

这里有个小坑:

ts
messages = result.messages

压缩以后要替换整个数组。

不能 push 摘要,也不适合原地 splice 几段消息。压缩后的结构已经变了:旧历史被摘要消息替代,最近消息重新接在后面。

如果只是 push 一条摘要,旧消息还在,token 并不会少。模型还会同时看到完整旧历史和摘要,反而更乱。

所以 compactIfNeeded 返回的是新的 messages,调用方直接接住。

这版能解决什么#

加完 context compaction 后,这个 Agent 终于有了一点“长任务”的样子。

它不再只能依赖无限增长的完整聊天记录,而是把早期过程折叠成任务状态。对 coding agent 来说,这个状态通常比原始对话更有用:

txt
用户目标是什么
已经碰过哪些文件
哪些命令跑过
结果是什么
下一步该做什么

也有明显限制。

第一,按消息数量触发太粗。消息短的时候可能过早压缩,工具结果很长的时候又可能压缩太晚。

第二,摘要质量依赖模型。如果摘要漏掉一个关键文件路径,后续就可能重复探索。

第三,工具消息的结构被转成文本了。这个 demo 可以接受,但更严肃的实现应该更仔细地格式化 tool call 和 tool result。

这些限制不影响理解这一层。lv5 不追求一步做出完美 memory system,它只先解决一个问题:Agent Loop 不能一直依赖越堆越长的完整历史,旧历史需要被整理成状态。

怎么运行#

kk-agent-lab 里跑:

bash
npm run lv5

启动以后可以连续问几轮文件或命令相关的问题。

中间输入:

txt
/info

可以看到当前 messages 数量。

当消息数量超过阈值,程序会调用 compactIfNeeded,生成摘要,然后把原来的 messages 替换掉。

如果想更快看到效果,可以把阈值临时改小:

ts
const result = await compactIfNeeded(messages, client, model, {
  threshold: 10,
  keepLast:  6,
})

这不一定适合真实使用,但很适合观察压缩前后 messages 的变化。

下一步#

现在这个 Agent 已经有了几块基础能力:

txt
tool use
agent loop
streaming
context compaction

下一层我想处理 provider adapter。

现在代码直接写的是 OpenAI-compatible 的 Chat Completions 格式。换到 Anthropic 或别的模型时,messagestools、tool result 的形状都会有差异。

如果这些差异都塞进 Agent Loop,后面会很难维护。

所以接下来要把模型接口包一层,让 Agent Loop 只面对统一的内部格式。