文章分析多轮 LLM 对话全量回传带来的成本与上下文上限问题,并指出简单按时间删除会误删系统指令、原始目标或破坏工具调用配对。其核心是将系统提示、摘要及完整工具交互作为不同层级管理,在预算内保留关键语义。
书籍:AI That Answers
系列文章:AI in TypeScript——共 5 本书,带你从第一次调用 LLM 一路走到在生产环境中运行 Agent——五本都在这里。
我的项目:Hermes IDE | GitHub——一款面向开发者的 IDE,适合使用 Claude Code 及其他 AI 编程工具交付产品。
关于我:xgabriel.com | GitHub
聊天历史的第一个版本通常就是 messages.push(...)。拿来做演示没问题,但它有两种失效模式,而且会依次出现。
首先是成本。每一轮对话都会重新发送整个数组,因此,一段五十轮的对话,相当于第一轮内容被付费发送了五十次。每轮成本线性增长,却没人察觉,直到某个月的账单突然变得出人意料。
然后是那堵墙。你会撞上 context 上限,这时就必须舍弃一些内容。无论当下负责截断的是 slice、while 循环,还是某个 SDK helper,它实际上都在决定 AI 应该记住什么——而且这个决定通常做得很糟。
最直接的截断方式是优先删除最旧的内容:
while (estimateTokens(messages) > LIMIT) {
messages.shift();
}
这样做会引发三个问题,而只有第三个会直接抛出错误。
system context 通常位于第一轮,因此最先被删掉的,恰恰是塑造整段对话行为的指令。如果你的 system prompt 放在数组里,而不是通过 system 参数传入,这段代码就会把它删掉。
用户最初的目标也往往出现在前面。在一场漫长的调试对话中,任务是在开头提出的;四十轮细节都保留下来了,核心目标却不见了。
此外,tool_use block 可能被删除,而与之匹配的 tool_result 仍然保留。这会形成一个格式错误的请求——API 返回 400。前一分钟还能正常工作的对话突然报错,而错误信息既没有指出原因,也没有告诉你如何修复。
export type Turn =
| { role: "user"; content: string }
| { role: "assistant"; content: ContentBlock[] }
| { role: "user"; content: ToolResultBlockParam[] };
export class Window {
private turns: Turn[] = [];
private summary: string | null = null;
constructor(
private readonly system: string,
private readonly budget: number,
private readonly summarise: Summariser,
) {}
add(t: Turn) { this.turns.push(t); }
render(): { system: string; messages: MessageParam[] } {
const head: MessageParam[] = this.summary
? [{ role: "user", content: `Earlier in this conversation:\n${this.summary}` },
{ role: "assistant", content: "Understood, continuing." }]
: [];
return { system: this.system, messages: [...head, ...this.turns] };
}
}
仅从这个结构来看,我们就已经做出了两个决定。
system prompt 不放在数组中,而是通过 system 参数传入。这样它就不会被逐出,同时还能构成一个稳定、可缓存的前缀。
summary 采用一组 user/assistant 消息,而不是一条孤立的 user 消息。有些模型处理悬空的 user 消息时表现很奇怪;一个完整的问答交换看起来才更像历史记录。
async compact(): Promise<void> {
if (this.tokens() <= this.budget) return;
const keep = this.safeTail(6);
const older = this.turns.slice(0, this.turns.length - keep.length);
if (older.length === 0) return;
this.summary = await this.summarise(this.summary, older);
this.turns = keep;
}
safeTail 是防止出现 400 错误的关键:
private safeTail(n: number): Turn[] {
let start = Math.max(0, this.turns.length - n);
// never begin on a tool_result whose tool_use was left behind
while (start < this.turns.length && isToolResult(this.turns[start])) {
start++;
}
return this.turns.slice(start);
}
不断向前移动,直到 tail 从一次完整的交互开始。仅仅四行代码,就消除了一整类生产环境错误。这类错误只会在长对话中出现,也就是说,最终往往只有真实用户才能遇到。

一句笼统的“总结这段对话”,得到的通常是一段读起来不错的文字,却会丢失下一轮真正需要的信息。
const summarise: Summariser = async (prev, turns) => {
const res = await client.messages.create({
model: "claude-sonnet-5",
max_tokens: 700,
system:
"You maintain a running brief so a conversation can continue after " +
"older turns are removed. Output a list. Preserve: the user's goal, " +
"decisions made and why, identifiers and values supplied, tools " +
"already run and their outcomes, and anything the user asked you to " +
"remember. Omit pleasantries and restatement. Under 250 words.",
messages: [{
role: "user",
content: (prev ? `Existing brief:\n${prev}\n\n---\n\n` : "") + render(turns),
}],
});
return textOf(res.content);
};
把 prev 重新传入,能够避免第十次压缩变成“摘要的摘要的摘要”。每一轮压缩都是在修订同一份持续更新的文档,而不是反复压缩上一次的输出——这正是一份始终清晰的简报,与逐渐退化成“用户询问了若干问题”的模糊摘要之间的区别。
“已经运行过的工具及其结果”值得单独占一行。如果没有这条要求,模型会在某次搜索结果被逐出三轮之后,再次执行同一个搜索。
const window = new Window(SYSTEM, 0.6 * CONTEXT_LIMIT, summarise);
使用百分之六十,而不是百分之九十五。原因有两个。
你需要为下一轮预留空间,因为它可能包含一份很大的 tool result。如果到了 95% 才压缩,就没有空间容纳它,而且你很快又得再次压缩。
另外,summarisation 调用本身也会消耗 token。在逼近上限时才触发压缩,意味着你会在最糟糕的时机执行昂贵的工作,而用户此时正在等待请求返回。
更好的做法,是在两轮对话之间进行压缩,而不是在某一轮执行过程中压缩:
res.on("finish", () => {
void window.compact().catch((e) => logger.warn("compaction failed", { e }));
});
用户已经拿到了答案;清理工作在请求链路之外进行。如果清理失败,下一轮只需改为同步压缩——体验有所降级,但系统不会崩溃。
压缩需要调用模型,因此并非没有成本。但与五十轮对话中反复发送完整历史相比,两者根本不在一个量级:一次 summarisation,就能替代后续每次请求都携带那四十轮已被逐出内容的重复成本。
真正应该关注的指标,是 token per turn 随对话长度的变化。没有压缩时,它会线性上升。有压缩时,它会呈锯齿状变化——先上升,在每次压缩时下降,然后再次上升,而平均值会大致保持稳定。
metrics.histogram("chat.tokens_per_turn", tokens, { turnIndex });
如果启用 window 后,这条曲线仍然持续上升,说明你的 summary 一直在增长,而不是被替换。也就是说,summariser 正在追加内容,而非修订现有内容。上面传入 prev 的做法正是解决这一问题的关键。

it("never renders a tool_result without its tool_use", async () => {
const w = new Window(SYSTEM, 500, fakeSummariser);
for (const t of longConversationWithTools) w.add(t);
await w.compact();
const { messages } = w.render();
for (const [i, m] of messages.entries()) {
if (isToolResult(m)) expect(hasPrecedingToolUse(messages, i)).toBe(true);
}
});
注入一个假的 summariser,可以让它保持为一个快速的单元测试,无须真正调用模型。它能在某位用户的对话长到足以触发问题之前,就发现这个格式错误请求的 bug。而在实际情况中,能把对话进行到这种长度的,往往是真实用户,而不是团队里的任何人。
AI That Answers 系统讲解了如何正确处理对话状态——window budget、能够保留决策的压缩、tool pair 安全性,以及长对话的成本究竟来自哪里。

Agent memory 是另一个不同的问题,会在第四本书中讨论。完整系列位于 xgabriel.com/ai-in-typescript。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。