上一篇我给 TypeScript Agent 加上了流式输出。
那一版已经能边生成边打印,也能处理流式工具调用里的参数碎片。体验好了很多,但 messages 还有一个老问题:它只会变长,不会变短。
每次用户输入,都会多一条 user 消息。模型回答,会多一条 assistant 消息。模型调用工具,还会多出 assistant.tool_calls 和 tool 结果。
短任务没事。长任务里,messages 很快会堆起来。每一轮请求又要把完整历史发给模型,input token 越来越多,速度和成本都会被拖住。再往后,就是 context window 不够用。
所以这一篇只做一件事:给 Agent 加 context compaction。
我现在的版本在 lv5-compact.ts:
用户输入
-> messages.push(user)
-> runStreamingAgent(messages)
-> Agent 追加 assistant / tool 消息
-> compactIfNeeded(messages)
-> 未超过阈值:原样继续
-> 超过阈值:旧历史压成摘要,替换 messages先看问题在哪里#
在前几篇里,messages 是 Agent 的记忆。
最小 Agent Loop 靠它保存上下文:
system
user
assistant
tool
assistant
user
assistant
...流式版本里,我还特意补了一条完整的 assistantMessage。因为打印给用户看的 token,不等于保存给下一轮模型看的历史。
这就带来一个副作用:Agent 越能干,messages 增长越快。
普通聊天一轮通常增加两条消息:
user -> assistant带工具调用时,一轮可能是四条甚至更多:
user
assistant: tool_calls
tool: result
assistant: final answer如果让一个 coding agent 连续读文件、跑命令、改文件,消息数量涨得很快。早期历史里可能有用的信息,比如用户最初的目标、已经读过的文件、执行过的命令;也可能有很多已经不重要的细节。
我不想直接删掉旧消息。删太狠,模型会丢任务状态。
更合适的做法是:保留最近几轮,把更早的历史压成一段摘要。
压缩后的 messages 长什么样#
我希望压缩前后,Agent Loop 看到的仍然是普通 messages。
也就是说,外层 loop 不需要知道“这里发生过压缩”。它只继续把 messages 传给模型。
压缩前大概是:
system
user 1
assistant 1
user 2
assistant 2
...
user 10
assistant 10压缩后变成:
system
user: [上下文摘要 - 之前的对话记录]
assistant: 好的,我已了解之前的工作进展,继续。
user 8
assistant 8
user 9
assistant 9
user 10
assistant 10这里有三块:
system 原样保留。它是 Agent 的基本行为约束。
摘要消息保存旧历史里的任务状态。
最近 N 条消息原样保留,因为当前对话附近的细节最容易影响下一步动作。
这样做有信息损失。摘要不可能等价于完整历史。但如果不压缩,长任务迟早会被 token 成本和上下文上限卡住。这里要接受一个取舍:旧细节变粗,当前状态留下。
compactIfNeeded 的入口#
压缩逻辑放在 src/context/compact.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 数判断,比如接近模型上下文窗口的某个比例时再触发。
把历史拆成三段#
真正压缩前,先把消息拆开:
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,摘要不能只写“用户让助手分析了项目”。这种摘要太空,下一轮模型没法继续干活。
它至少要记住这些东西:
用户的最终目标
已读取过的文件路径
已创建或修改的文件路径和内容要点
已执行的命令和关键结果
当前进展和下一步计划这些信息都和后续动作有关。文件路径丢了,模型可能重复读文件;命令结果丢了,它可能重复执行;当前进展丢了,它会从头开始猜。
让模型生成任务摘要#
toCompress 里的消息会先转成一段文本:
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')然后发起一次独立的模型请求:
const summaryRes = await client.chat.completions.create({
model,
messages: [
{
role: 'user',
content: `请把下面的对话历史压缩成简洁的任务状态摘要。必须保留:
1. 用户的最终目标
2. 已读取过的文件路径
3. 已创建/修改的文件路径和内容要点
4. 已执行的命令和关键结果
5. 当前进展和下一步计划
对话历史如下:
${historyText}
请用简洁的要点格式输出摘要,不要重复原始对话内容。`,
},
],
})这次请求不走主 Agent Loop,也不带工具。它只负责把旧历史写成任务摘要。
这里我没有给摘要单独换模型。demo 里复用同一个 model,代码少一点。实际用的时候,可以把摘要交给更便宜的模型,只要它能稳定保留任务状态。
摘要回来以后,取出文本:
const summary = summaryRes.choices[0]?.message?.content ?? '(摘要生成失败)'这里还有一个可以继续改的点:如果摘要生成失败,现在只是塞一段“摘要生成失败”。更稳的做法是压缩失败就返回原 messages,并打印错误,让主对话不要因为压缩挂掉。
摘要怎么放回 messages#
压缩不是在原数组上删删改改。
我最后重新拼一个新的 messages:
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 中间压缩。
压缩发生在一轮用户请求完成之后:
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、最终回答还没形成完整闭环。中途压缩,容易把一组相关消息切散。
替换整个数组#
这里有个小坑:
messages = result.messages压缩以后要替换整个数组。
不能 push 摘要,也不适合原地 splice 几段消息。压缩后的结构已经变了:旧历史被摘要消息替代,最近消息重新接在后面。
如果只是 push 一条摘要,旧消息还在,token 并不会少。模型还会同时看到完整旧历史和摘要,反而更乱。
所以 compactIfNeeded 返回的是新的 messages,调用方直接接住。
这版能解决什么#
加完 context compaction 后,这个 Agent 终于有了一点“长任务”的样子。
它不再只能依赖无限增长的完整聊天记录,而是把早期过程折叠成任务状态。对 coding agent 来说,这个状态通常比原始对话更有用:
用户目标是什么
已经碰过哪些文件
哪些命令跑过
结果是什么
下一步该做什么也有明显限制。
第一,按消息数量触发太粗。消息短的时候可能过早压缩,工具结果很长的时候又可能压缩太晚。
第二,摘要质量依赖模型。如果摘要漏掉一个关键文件路径,后续就可能重复探索。
第三,工具消息的结构被转成文本了。这个 demo 可以接受,但更严肃的实现应该更仔细地格式化 tool call 和 tool result。
这些限制不影响理解这一层。lv5 不追求一步做出完美 memory system,它只先解决一个问题:Agent Loop 不能一直依赖越堆越长的完整历史,旧历史需要被整理成状态。
怎么运行#
在 kk-agent-lab 里跑:
npm run lv5启动以后可以连续问几轮文件或命令相关的问题。
中间输入:
/info可以看到当前 messages 数量。
当消息数量超过阈值,程序会调用 compactIfNeeded,生成摘要,然后把原来的 messages 替换掉。
如果想更快看到效果,可以把阈值临时改小:
const result = await compactIfNeeded(messages, client, model, {
threshold: 10,
keepLast: 6,
})这不一定适合真实使用,但很适合观察压缩前后 messages 的变化。
下一步#
现在这个 Agent 已经有了几块基础能力:
tool use
agent loop
streaming
context compaction下一层我想处理 provider adapter。
现在代码直接写的是 OpenAI-compatible 的 Chat Completions 格式。换到 Anthropic 或别的模型时,messages、tools、tool result 的形状都会有差异。
如果这些差异都塞进 Agent Loop,后面会很难维护。
所以接下来要把模型接口包一层,让 Agent Loop 只面对统一的内部格式。