🚀 欢迎来到「不用框架,手搓 AI Agent」系列第五篇。
即使没有读过前面的文章,也可以直接从这一篇开始。你只需要知道:我们已经有了一个能调用工具、读取和修改文件、执行命令的最小 AI Agent。
不过,这个 Agent 还有一个明显的问题:每次处理新问题时,它都像第一次见到你,不记得前面聊过什么。即使我们把所有消息一直保 存在内存里,对话变长后,也迟早会塞满模型有限的上下文窗口。
这一篇,我们不换模型,也不引入 Agent 框架,而是从零给它补上两项基础能力:用会话记录保留对话历史,用上下文压缩控制长期对 话的长度。
最终,我们会得到一个可以在终端里连续追问,并在对话过长时自动整理早期内容的 Agent。
系列目录:
- 不用框架,手搓 AI Agent:(一)先让它跑起来
- 不用 LangChain,手搓 AI Agent:给大模型装上"手",让它自己读项目文件
- 原来 AI Agent 的核心循环这么简单:手搓一个 Agent Loop
- Claude Code 是怎么自己改代码的?答案藏在这 4 个工具里
- 本文: :不用LangChain:用 200 行代码手搓 Agent 对话记忆与上下文压缩
- 更多实战持续更新中......
先来"放个毒",看看经过本系列的打磨,我们最终会亲手搞出一个怎样的"完全体"

先看看当前版本为什么会"失忆"
第四篇的 Agent 已经可以循环调用工具,大概会走这样的流程:
text
用户输入任务
↓
Agent.run() 在函数里新建 messages上下文数组
↓
模型决定调用工具
↓
Registry 执行工具,并把结果放回 messages
↓
模型回答
↓
程序结束
问题藏在 src/agent.ts 里:
ts
async run(prompt: string): Promise<string> {
const messages = [
{ role: "system", content: "..." },
{ role: "user", content: prompt },
];
// 在这里循环调用模型和工具
}
messages 是 run() 的局部变量。函数返回以后,它也就跟着消失了。
所以,下面两次启动之间没有任何记忆:
bash
npm start -- -prompt "先读 README.md"
npm start -- -prompt "继续,告诉我这个项目怎么启动"
第一次 run() 执行结束后,局部上下文数组变量 messages 不再被引用,随后会由 JavaScript 引擎回收。第二次执行 npm start 时,启动的是一个全新的进程:它的内存里没有上一次的消息,只会把"继续"作为新的用户输入发给模型。模型自然不知道之前读过哪个文件,也不知道任务进行到了哪一步。
所以问题不在于模型突然"变笨"或主动"忘记"了,而在于模型每次只能看到当前请求携带的上下文。我们既没有保存上一轮消息,也没有在新请求中重新带上它们,它当然无法接着之前的任务继续工作。
这篇文章最后要做成什么
如果你使用过 Claude Code 或 Codex CLI,这篇文章最终要实现的效果会很熟悉:启动 powercode 程序后,不再是问完一个问题程序就退出,而是可以留在终端里继续追问。
text
$ npm start
> 先看看当前工作区里有哪些文件,告诉我大概结构
AI:......
> 那里面主要有什么内容?
AI:......
> 继续,帮我看看有没有值得注意的地方
AI:......
后面两次输入里的"那"和"继续",都依赖前面的对话。powercode 会保留当前会话的消息,因此模型知道你们刚才讨论过什么,不需要每次都把背景重新说一遍。
为了得到这样的使用体验,我们会逐步给当前的 powercode 加上三块基础能力:
- 用
Session保存同一进程里的聊天历史; - 用交互循环支持在终端连续输入和追问;
- 对过长的历史进行摘要,避免上下文越积越多;
可以先记住最终效果:
启动一次程序后可以连续追问;对话变长时,较早内容自动变成摘要,最近内容继续保留原文。
至于进程重启后怎样继续,以及怎样实现类似 Claude Code、Codex 的 Plan Mode,我们放到下一篇单独完成。
🚀 本节配套源码:powercode 👈点它
如果中途遇到问题,可以对照源码排查。后续章节的代码也会持续更新,如果这个项目对你有帮助,欢迎点个 Star ⭐
先说明主项目和文章的对应关系
本文对应的是 powercode。它是第四篇结束时的主项目,核心文件如下:
text
powercode/
├── provider.json # 模型配置,从项目根目录读取
├── src/
│ ├── agent.ts # Agent Loop
│ ├── chat.ts # OpenAI 兼容客户端
│ ├── config.ts # provider.json 配置
│ ├── main.ts # CLI 入口
│ └── tools/ # Registry 和四个工具
└── workspace/ # Agent 实际读写的工作区
这里有两个容易混淆的目录:
text
powercode/src
我们正在开发的 Agent 源码,本文新增的 Session、Compactor 都写在这里
powercode/workspace
交给 Agent 操作的工作区,工具读取、修改和生成的演示文件都放在这里
所以本文始终在 powercode 项目根目录执行 npm run build 和 npm start,但 Agent 的四个工具只操作 workspace,不会拿自己的 src 目录练手。后面的压缩演示脚本会把文件集中放进 workspace/context-demo,不会影响工作区里的其他内容。
本文会直接在这份主项目代码上继续开发,不需要切换到其他项目或副本。后面的步骤会依次加入会话记忆和上下文压缩,最终得到前面展示的连续交互效果。
第一步:把局部 messages 交给 Session
Session 到底是什么
先别把"记忆"想得太玄。
Session 就是一只装消息的文件夹:用户说的话、模型的回复、工具返回的结果,都按发生顺序放进去。
text
Session
├── 用户问题
├── 模型的工具调用
├── 工具执行结果
├── 模型的下一次回复
└── 后续用户问题
这一步先只解决一个问题:同一个进程里,下一次调用 Agent 时还能取到上一次的消息。
它还不是永久记忆。进程退出后,内存里的 Session 也会消失;跨重启的任务状态,后面交给 PLAN.md 和 TODO.md。
新建 src/context/session.ts
ts
import type OpenAI from "openai";
// 会话中的消息类型
export type Message =
OpenAI.Chat.Completions.ChatCompletionMessageParam;
// 会话类
export class Session {
readonly createdAt = new Date(); // 创建时间
updatedAt = new Date(); // 更新时间
private readonly history: Message[] = []; // 会话历史
/**
* 追加消息到会话历史
* @param messages 要追加的消息
*/
append(...messages: Message[]): void {
this.history.push(...messages);
this.updatedAt = new Date();
}
/**
* 获取会话历史
* @returns 会话历史
*/
getHistory(): Message[] {
return structuredClone(this.history);
}
}
这里先看最重要的两个方法:
ts
session.append(message); // 按顺序保存新消息
session.getHistory(); // 取出完整历史
getHistory() 使用 structuredClone() 返回一份深拷贝。Agent 后续无论增删数组元素,还是修改其中的消息对象,都不会影响 Session 内部保存的历史。
修改 src/agent.ts,不再自己创建 messages
先看原来的做法:
ts
const messages = [
{ role: "system", content: "..." },
{ role: "user", content: prompt },
];
现在把这件事改成:
text
收到用户输入
↓
写入 Session
↓
每次请求模型时,从 Session 取出完整历史
↓
模型回复和工具结果继续写回 Session
用下面的内容完整替换 src/agent.ts:
ts
import type OpenAI from "openai";
import { ChatClient } from "./chat.ts";
import type { Session } from "./context/session.ts";
import { Registry } from "./tools/registry.ts";
const MAX_STEPS = 8; // 最大步骤数
export class Agent {
constructor(
private readonly client: ChatClient, // 聊天客户端
private readonly registry: Registry, // 工具注册表
private readonly session: Session, // 会话
) {}
/**
* 运行智能体
* @param prompt 提示词
* @returns 智能体回答
*/
async run(prompt: string): Promise<string> {
// 追加用户提示词到会话历史
this.session.append({
role: "user",
content: prompt,
});
// agent loop
for (let step = 1; step <= MAX_STEPS; step += 1) {
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{
role: "system",
content:
"你是 power-code,一个研发助手。请优先读取真实文件;修改后主动运行命令验证结果。请使用中文回答。",
},
...this.session.getHistory(), // 追加会话历史
];
// 调用模型
const message = await this.client.complete(
messages,
this.registry.getDefinitions(), // 传递工具列表定义
);
if (!message) {
throw new Error("模型没有返回消息。");
}
// 追加模型回复到会话历史
this.session.append(message);
const toolCalls = message.tool_calls ?? [];
if (toolCalls.length === 0) {
return message.content ?? "模型没有返回文本内容。";
}
// 处理工具调用
for (const toolCall of toolCalls) {
if (toolCall.type !== "function") {
throw new Error(`暂时不支持工具类型:${toolCall.type}`);
}
const name = toolCall.function.name;
console.log(`第 ${step} 轮:AI 调用 ${name}`);
let result: string;
try {
// 执行工具调用
result = await this.registry.execute(
name,
toolCall.function.arguments,
);
console.log(`✓ ${name} 执行完成\n`);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
result = `工具执行失败:${reason}`;
console.log(`✗ ${result}\n`);
}
// 追加工具调用结果到会话历史
this.session.append({
role: "tool",
tool_call_id: toolCall.id,
content: result,
});
}
}
throw new Error(`执行超过 ${MAX_STEPS} 轮,已停止。`);
}
}
工具执行成功后,终端只显示工具名称和完成状态,不再把文件内容、命令日志等完整结果全部打印出来。完整的 result 仍会作为 tool 消息写入 Session,因此模型可以继续使用真实结果;这里只是暂时省略面向用户的展示。等后面升级 TUI 时,再把工具结果放进可以展开和折叠的区域。
这段代码和第四篇最大的区别只有一个:messages 不再是记忆的主人。
text
第四篇:Agent 自己创建并保存 messages
第五篇:Session会话类 保存历史,Agent 每次取出来使用
注意 system message 没有写进 Session。它是每次请求都要带上的运行规则,不是任务历史;所以每一轮重新拼上就好。
让入口创建并传入 Session
打开 src/main.ts,增加导入:
ts
import { Session } from "./context/session.ts";
找到原来的:
ts
const agent = new Agent(client, registry);
替换成:
ts
const session = new Session();
const agent = new Agent(client, registry, session);
现在先不急着改命令行交互,运行一次构建:
bash
npm run build
如果通过,说明 Session 已经接进 Agent,但因为入口仍然只调用一次 agent.run(),还看不出连续对话的效果。
第二步:让一个进程可以连续接收任务
Agent Loop 和终端循环不是一回事
这里很容易混淆两个循环:
text
Agent Loop
模型 → 工具 → 模型 → 工具 → 模型回答
负责完成当前这一条任务
终端交互循环
用户输入 → Agent Loop → 再等待用户输入
负责让程序不要完成一条任务就退出
上一篇实现的是第一种循环。这一篇还要在它外面加第二种循环。
修改 src/main.ts
这一步先只做交互,不加入 --plan 和 -dir,让改动保持小一点。
用下面的内容完整替换 src/main.ts:
ts
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { resolve } from "node:path";
import { Agent } from "./agent.ts";
import { ChatClient } from "./chat.ts";
import { loadConfig } from "./config.ts";
import { Session } from "./context/session.ts";
import { BashTool } from "./tools/bash.ts";
import { EditFileTool } from "./tools/edit-file.ts";
import { ReadFileTool } from "./tools/read-file.ts";
import { Registry } from "./tools/registry.ts";
import { WriteFileTool } from "./tools/write-file.ts";
async function main() {
const config = await loadConfig();
const client = new ChatClient(config);
const workDir = resolve(process.cwd(), "workspace");
const registry = new Registry();
registry.register(new ReadFileTool(workDir));
registry.register(new WriteFileTool(workDir));
registry.register(new EditFileTool(workDir));
registry.register(new BashTool(workDir));
const session = new Session();
const agent = new Agent(client, registry, session);
const runPrompt = async (value: string) => {
console.log("AI 正在思考...\n");
const answer = await agent.run(value);
console.log(`AI:${answer}`);
};
const readline = createInterface({ input, output });
try {
console.log("已进入交互模式,输入 exit 或 quit 退出。\n");
while (true) {
const value = (await readline.question("> ")).trim();
if (!value) continue;
if (value === "exit" || value === "quit") break;
try {
await runPrompt(value);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`本轮执行失败:${message}`);
}
}
} finally {
readline.close();
}
}
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`启动失败:${message}`);
process.exit(1);
});
这里把 workDir 固定为 workspace:程序配置和 Agent 源码仍然从 powercode 根目录加载,但 read_file、write_file、edit_file 和 bash 都只在工作区中执行。下一篇再把这个固定目录升级成可以通过 -dir 选择的命令行参数,并引入具体的任务目录。
为什么程序会一直等待输入
关键在下面这几行:
ts
const readline = createInterface({ input, output });
while (true) {
const value = (await readline.question("> ")).trim();
if (!value) continue;
if (value === "exit" || value === "quit") break;
await runPrompt(value);
}
readline 是 Node.js 自带的终端输入工具,可以把它理解成一个"接线员":负责把你在终端里输入的内容交给程序。
createInterface() 会创建这个接线员。readline.question("> ") 先显示一个 >,然后停在这里等你输入。你按下回车后,程序才会继续往下走,把内容交给 Agent。
Agent 回答完以后,while (true) 会回到开头,再等你输入下一句话:
text
显示 > 并等待输入
↓ 用户按下回车
Agent 处理并回答
↓
回到循环开头
↓
再次显示 > 并等待输入
它不会变成一直空转的死循环,因为每一轮都会停在 await readline.question() 等你。当你输入 exit 或 quit 时,break 会结束循环,readline.close() 再把终端输入关闭,程序就退出了。
现在直接运行 npm start 就会进入交互模式。程序回答完一个问题后不会退出,而是继续等待你输入下一个问题。
bash
npm start
交互过程中输入 exit 或 quit,才会结束程序并返回普通的终端命令行。
关键是这两行在循环外面:
ts
const session = new Session();
const agent = new Agent(client, registry, session);
如果把它们放进 while 循环,每次用户输入都会拿到一份全新的 Session,效果就又回到"失忆"。
先跑一个最小验证
bash
npm run build
npm start
在终端中依次输入:
text
> 先读取 README.md,告诉我这个项目是什么
> 那它现在有哪些工具?
> exit
第二个问题里的"那"之所以有机会被正确理解,是因为同一个进程里的 Session 仍然保存着第一轮消息。
可以把它想成同事一直坐在会议室里:每次只开一个新的讨论话题,但会议纪要没有被清空。
这一步完成后,先停下来再构建一次:
bash
npm run build
第三步:历史太长时,不要把全部内容原样塞给模型
为什么不能永远保留完整历史
现在 Session 会保存全部消息,看起来已经"有记忆"了。
但如果 Agent 连续读取文件、运行命令、处理报错,消息会变成这样:
text
用户问题
→ README.md 的内容
→ package.json 的内容
→ 第一次构建日志
→ 修改后的文件内容
→ 第二次构建日志
→ 一大段失败堆栈
→ 用户补充的新要求
如果每次都把完整历史原样发给模型,会出现两个问题:
- 输入越来越长,成本和延迟越来越高;
- 一次大日志可能把真正重要的新问题挤出上下文。
最粗暴的做法是只保留最后 20 条消息,但它也不可靠:第 21 条可能刚好保存着一个关键决定,而一条工具输出可能比 20 句用户消息还长。
所以我们把"保存什么"和"这一轮发什么"分开:
text
Session.history
保存完整历史,方便追溯
工作记忆
较早消息的摘要 + 最近消息的原文
只作为当前请求的输入
这就是上下文压缩。它不会删除 Session 里的原始历史,只改变这一轮给模型看的内容。
把模型上下文上限写进配置
不同模型能容纳的 Token 数量不同,所以不能把上限固定写在 Compactor 里。先在项目根目录的 provider.json 中增加 contextWindow:
json
{
"baseURL": "https://api.example.com/v1",
"apiKey": "replace-with-your-api-key",
"model": "your-model-name",
"contextWindow": 128000
}
contextWindow 要填写当前模型真实的上下文上限,具体数值以模型服务商的文档为准。切换模型时,只需要一起修改这里。
接着修改 src/config.ts 中的配置类型:
ts
export interface ProviderConfig {
baseURL: string;
apiKey: string;
model: string;
contextWindow: number;
}
在 loadConfig() 中把 contextWindow 加入校验:
ts
if (
!config.baseURL ||
!config.apiKey ||
!config.model ||
typeof config.contextWindow !== "number" ||
!Number.isInteger(config.contextWindow) ||
config.contextWindow <= 0
) {
throw new Error(
"provider.json 配置不完整,需要提供 baseURL、apiKey、model 和正整数 contextWindow。",
);
}
最后在返回值中带上它:
ts
return {
baseURL: config.baseURL,
apiKey: config.apiKey,
model: config.model,
contextWindow: config.contextWindow,
};
再让 ChatClient 返回 Token 用量
模型每次回答时,接口通常会在 usage.total_tokens 中返回这次请求实际使用了多少 Token。与其全部靠猜,不如优先使用这个真实数字;只有最后一次请求之后新增的消息,才继续使用粗略估算。
前面的 ChatClient 使用的是 complete()。它调用大模型 API 后,只把模型回复的 message 返回给 Agent,接口同时返回的 Token 用量并没有保留下来。
现在 Compactor 需要根据上下文用量判断是否应该压缩,所以我们把 complete() 改成 completeWithUsage()。新方法仍然只请求一次大模型,只是返回值从单独的 message 变成:
text
message 模型回复
totalTokens 本次请求实际使用的 Token 数量
进入第三步后,所有模型请求都统一使用这个新方法。用下面的完整版本替换 src/chat.ts:
ts
import OpenAI from "openai";
import type { ProviderConfig } from "./config.ts";
export interface CompletionResult {
message:
| OpenAI.Chat.Completions.ChatCompletionMessage
| undefined;
totalTokens: number | undefined;
}
export class ChatClient {
private readonly client: OpenAI;
constructor(private readonly config: ProviderConfig) {
this.client = new OpenAI({
apiKey: config.apiKey,
baseURL: config.baseURL,
});
}
getContextWindow(): number {
return this.config.contextWindow;
}
async completeWithUsage(
messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[],
tools: OpenAI.Chat.Completions.ChatCompletionTool[],
): Promise<CompletionResult> {
const response = await this.client.chat.completions.create({
model: this.config.model,
messages,
tools,
});
return {
message: response.choices[0]?.message,
totalTokens: response.usage?.total_tokens,
};
}
}
completeWithUsage() 本质上仍然只调用一次大模型 API,只是把原来丢掉的 usage.total_tokens 和模型消息一起返回,并不会额外多发一次请求。
它会在 Agent Loop 每次请求模型时调用。到后面把 Compactor 接进 agent.ts 时,原来的:
ts
const message = await this.client.complete(messages, tools);
会替换成:
ts
const completion = await this.client.completeWithUsage(
messages,
tools,
);
const message = completion.message;
完整流程是:
text
Agent 准备本轮消息
↓
completeWithUsage() 请求模型
↓
模型返回 message + usage
↓
message 写入聊天历史
usage 写入 Session 的计数快照
↓
下一轮 Compactor 用它判断是否需要压缩
Compactor 生成摘要时也调用 completeWithUsage(),不过它只取返回的 message,不保存 totalTokens。因为这份用量属于"生成摘要"这次临时请求,不是主会话的上下文用量。
所以从第三步开始,complete() 就不再需要了,所有模型请求都统一走 completeWithUsage()。
给 Session 增加压缩状态
现在重新打开 src/context/session.ts,用下面的完整版本替换第一步的版本:
ts
import type OpenAI from "openai";
// 会话中的消息类型
export type Message = OpenAI.Chat.Completions.ChatCompletionMessageParam;
// 会话中的压缩状态
// 用于记录压缩到的步骤数和压缩后的摘要
export interface CompactionState {
summary: string;
compactedUntil: number;
}
// 最近一次模型请求返回的 Token 用量
export interface UsageSnapshot {
totalTokens: number;
historyLength: number;
}
// 会话类
export class Session {
readonly createdAt = new Date(); // 创建时间
updatedAt = new Date(); // 更新时间
private readonly history: Message[] = []; // 会话历史
private compaction?: CompactionState; // 压缩状态
private usage?: UsageSnapshot; // 最近一次 Token 用量
/**
* 添加消息到会话历史
* @param messages 要追加的消息
*/
append(...messages: Message[]): void {
this.history.push(...messages);
this.updatedAt = new Date();
}
/**
* 获取会话历史
* @returns 会话历史
*/
getHistory(): Message[] {
return structuredClone(this.history);
}
/**
* 获取压缩状态
* @returns 压缩状态
*/
getCompaction(): CompactionState | undefined {
return this.compaction === undefined ? undefined : { ...this.compaction };
}
getUsage(): UsageSnapshot | undefined {
return this.usage === undefined ? undefined : { ...this.usage };
}
saveUsage(totalTokens: number): void {
this.usage = {
totalTokens,
historyLength: this.history.length,
};
this.updatedAt = new Date();
}
/**
* 保存压缩状态
* @param summary 压缩后的摘要
* @param compactedUntil 压缩到的步骤数
*/
saveCompaction(summary: string, compactedUntil: number): void {
this.compaction = { summary, compactedUntil };
// 压缩后上下文已经改变,旧 usage 不能继续使用。
this.usage = undefined;
this.updatedAt = new Date();
}
}
Session 现在会保存两份状态,但它们负责的事情不同:
text
usage 估算当前上下文有多长,判断要不要压缩
compaction 记录上次压缩的结果,决定这次从哪里继续压缩
也就是说,程序会先用 usage 判断"是否超出预算"。只有需要压缩时,才会读取 compaction,继续处理还没有被摘要覆盖的历史。
先看 usage。它是最近一次模型请求留下的计数快照:
text
totalTokens 接口返回的真实 Token 总数
historyLength 这份 Token 数已经覆盖到 history 的哪一条消息
例如,模型回答后,history 一共有 5 条消息,接口返回 totalTokens = 3200。Session 就保存:
ts
{ totalTokens: 3200, historyLength: 5 }
这表示前 5 条消息已经有真实 Token 数据。后来又追加了第 6、7 条消息,但还没有再次请求模型,接口当然还没有统计它们。此时程序不用重新估算全部历史,只需要这样计算:
text
当前上下文大小 = 3200 + 第 6、7 条消息的估算 Token
如果没有超出预算,就继续使用当前消息;如果超出了,才进入压缩。这时就轮到 compaction 发挥作用了:
text
summary 已经压缩好的较早对话摘要
compactedUntil 下一次从 history 的哪个下标继续处理
继续沿用这个过程。假设对话增长到 10 条消息,下标是 0 到 9,并且刚才的 Token 计算发现上下文已经太长。第一次压缩会把较早的 0 到 5 整理成摘要,保留近期的 6 到 9 原文:
text
history
[0 1 2 3 4 5] [6 7 8 9]
└── 变成摘要 ─┘ └─ 仍保留原文 ─┘
summary = 消息 0~5 的摘要
compactedUntil = 6
compactedUntil = 6 表示下标 0 到 5 已经处理过,下一次应该从 history[6] 继续。代码通过下面这一句跳过已经压缩的部分:
ts
const pending = history.slice(compactedUntil);
压缩改变了下一次真正发给模型的内容,原来的 Token 快照也就不准确了,所以 saveCompaction() 会先清空旧 usage。下一次模型回答后,接口会返回新工作记忆的真实 Token 数,Session 再保存一份新的 usage。
后来对话继续增长,并再次超过预算。假设这次又要把消息 6、7 收进摘要,就不需要重新总结消息 0 到 5,而是在上一次摘要的基础上继续更新:
text
新的 summary = 上一次 summary + 消息 6、7
最近原文 = 消息 8、9
compactedUntil = 8
整个过程可以概括为:usage 负责判断"什么时候压缩",summary 保存"前面聊过什么",compactedUntil 记录"已经压缩到哪里"。三者配合,程序既能判断上下文是否太长,也能在下一次压缩时从上次停下的位置继续。
新建 src/context/compactor.ts
Compactor 是 Agent 请求模型前使用的上下文整理器。
ts
import { ChatClient } from "../chat.ts";
import type { Message, Session } from "./session.ts";
const SUMMARY_PROMPT = `
你负责压缩一个 AI 编程任务的较早对话。
请只保留后续完成任务真正需要的信息,并严格使用以下结构:
## 用户目标
## 约束与关键决定
## 已完成
## 当前问题
## 已读取或修改的文件
## 下一步
不要编造对话中没有出现的结论。
`;
export class Compactor {
constructor(
private readonly client: ChatClient,
private readonly maxContextTokens: number,
private readonly reserveTokens = 16_384,
private readonly keepRecentTokens = 20_000,
) {}
async buildWorkingMemory(session: Session): Promise<Message[]> {
const history = session.getHistory();
const state = session.getCompaction();
const start = state?.compactedUntil ?? 0;
const pending = history.slice(start);
const current = this.withSummary(state?.summary, pending);
const usableTokens = this.maxContextTokens - this.reserveTokens;
const contextTokens = estimateContextTokens(
session,
history,
current,
);
if (contextTokens <= usableTokens) {
return current;
}
const cutIndex = this.findCutIndex(pending);
// 没找到安全的分界线时,暂不生成摘要,只截短过长的工具输出。
if (cutIndex <= 0) {
return this.withSummary(
state?.summary,
truncateLargeToolOutputs(pending),
);
}
console.log(
`上下文接近上限(约 ${contextTokens} / ${usableTokens} Token),正在压缩较早消息...`,
);
const older = truncateLargeToolOutputs(pending.slice(0, cutIndex));
const recent = truncateLargeToolOutputs(pending.slice(cutIndex));
const summaryInput = [
state?.summary ? `# 上一次摘要\n${state.summary}` : "",
`# 新增历史\n${JSON.stringify(older, null, 2)}`,
]
.filter(Boolean)
.join("\n\n");
// 摘要请求只取 message,不把 usage 保存到主 Session。
const completion = await this.client.completeWithUsage(
[
{ role: "system", content: SUMMARY_PROMPT },
{ role: "user", content: summaryInput },
],
[],
);
const summary = completion.message?.content?.trim() ?? "";
if (!summary) {
return this.withSummary(
state?.summary,
truncateLargeToolOutputs(pending),
);
}
session.saveCompaction(summary, start + cutIndex);
console.log("✓ 上下文压缩完成,近期消息继续保留原文。\n");
return this.withSummary(summary, recent);
}
private findCutIndex(messages: Message[]): number {
let recentTokens = 0;
for (let index = messages.length - 1; index >= 0; index -= 1) {
recentTokens += estimateMessageTokens(messages[index]);
if (recentTokens < this.keepRecentTokens) continue;
// 从普通 user 消息开始保留,避免把 assistant 工具调用和 tool 结果拆开。
for (let cut = index; cut < messages.length; cut += 1) {
if (messages[cut].role === "user") {
return cut;
}
}
}
return 0;
}
private withSummary(
summary: string | undefined,
messages: Message[],
): Message[] {
if (!summary) return messages;
const summaryMessage: Message = {
role: "user",
content: `[系统生成的较早会话摘要]\n${summary}`,
};
return [summaryMessage, ...messages];
}
}
function estimateContextTokens(
session: Session,
history: Message[],
current: Message[],
): number {
const usage = session.getUsage();
// 还没有真实 usage 时,估算当前工作记忆。
if (usage === undefined) {
return estimateTokens(current);
}
// usage 覆盖之前的消息,只估算它之后新增的部分。
const trailingMessages = history.slice(usage.historyLength);
return usage.totalTokens + estimateTokens(trailingMessages);
}
function estimateTokens(messages: Message[]): number {
return messages.reduce(
(total, message) => total + estimateMessageTokens(message),
0,
);
}
function estimateMessageTokens(message: Message): number {
const bytes = Buffer.byteLength(JSON.stringify(message), "utf8");
// 只用于估算尚未包含在 API usage 中的新消息。
return Math.ceil(bytes / 4);
}
function truncateLargeToolOutputs(messages: Message[]): Message[] {
return messages.map((message) => {
if (message.role !== "tool" || typeof message.content !== "string") {
return message;
}
const content = message.content;
if (content.length <= 8_000) {
return message;
}
return {
...message,
content:
`${content.slice(0, 4_000)}\n\n` +
"...[中间工具输出已截断]...\n\n" +
content.slice(-4_000),
};
});
}
完整代码先放在这里,是为了方便复制。下面先不用试图一次看懂全部实现,我们再按照它的执行顺序逐步拆开。
先看文件顶部引入的 ChatClient、Session 和消息类型:
ts
import { ChatClient } from "../chat.ts";
import type { Message, Session } from "./session.ts";
ChatClient 用来请求模型生成摘要,Session 用来读取完整历史和保存压缩进度。
接着看摘要提示词:
ts
const SUMMARY_PROMPT = `
你负责压缩一个 AI 编程任务的较早对话。
请只保留后续完成任务真正需要的信息,并严格使用以下结构:
## 用户目标
## 约束与关键决定
## 已完成
## 当前问题
## 已读取或修改的文件
## 下一步
不要编造对话中没有出现的结论。
`;
这段提示词规定了摘要必须保留的内容。这样生成的不是一段模糊概括,而是一份可以继续完成任务的交接记录。
再看 Compactor 的构造函数:
ts
export class Compactor {
constructor(
private readonly client: ChatClient,
private readonly maxContextTokens: number,
private readonly reserveTokens = 16_384,
private readonly keepRecentTokens = 20_000,
) {}
这里的三个参数分别控制:模型上下文上限、给下一次回答预留的空间,以及压缩时希望保留多少近期原文。maxContextTokens 稍后会从 provider.json.contextWindow 传进来。
16_384 和 20_000 采用的是 Pi Agent 当前的默认值:
text
reserveTokens = 16_384 给模型本轮回答预留空间
keepRecentTokens = 20_000 保留最近约 2 万 Token 的原文
为什么不把所有历史都变成摘要,而要专门保留最近一段原文?一方面,在编程任务中,最近的消息通常包含模型当前最需要的细节,例如用户刚补充的要求、刚读取的文件内容、刚执行的命令结果,以及还没有完成的工具调用。摘要可以保留结论,却难免损失具体代码和参数。
另一方面,这也和长上下文模型常见的位置偏差有关。《Lost in the Middle: How Language Models Use Long Contexts》发现:关键信息位于上下文开头或结尾时,模型通常表现更好;位于很长上下文的中间时,利用效果可能明显下降。这种现象常被称为"首因效应和近因效应",整体表现类似一条 U 形曲线,而不是简单地"内容越靠后,模型就一定越重视"。
因此,保留近期原文首先是为了保存当前任务的精确细节;同时,下面这种排列方式也与论文观察到的位置偏差相吻合:
text
系统提示词
较早内容的结构化摘要 放在前面,保留目标和关键决定
最近一段对话的原文 放在后面,保留当前任务的精确细节
需要说明的是,论文只能解释"为什么值得保留近期原文",并没有证明必须保留 2 万 Token。20_000 是 Pi Agent 采用的工程默认值,是质量、成本和上下文空间之间的一种折中,不是从论文中计算出来的理论常数。
它们并不是所有模型都必须使用的固定标准,只是对于本文配置的 128K 上下文模型,是一组比较稳妥的起点。如果以后换成上下文更小的模型,就要跟着调小。
这里最容易误解的是:reserveTokens = 16_384 并不是要求模型"把摘要压缩到 16K",也不是代码给输出设置了 16K 的硬上限。它只是让程序提前触发压缩,避免输入消息占满整个上下文窗口。
以 128K 上下文为例:
text
可使用的输入预算 = 128_000 - 16_384 = 111_616
当工作记忆超过约 11.1 万 Token 时,程序就开始压缩。这样压缩后的正常请求仍有一块空间可以留给模型回答。当前代码没有传入 max_tokens,所以实际最多能输出多少,仍由模型和服务商决定。
触发压缩后,keepRecentTokens = 20_000 会让最近约 2 万 Token 保留原文,其余较早内容交给模型生成摘要。如果以前已经压缩过,摘要请求不会重新携带那些更早的原始消息,而是使用:
text
上一次摘要 + 上次压缩之后新增的较早消息
模型更新摘要后,本轮真正用于正常回答的工作记忆是:
text
系统提示词 + 新的摘要 + 最近约 2 万 Token 的原文
这里的"2 万 Token"只是一个目标值,不保证刚好等于 20_000。
这里所说的"一轮对话",不是简单地只有一条提问和一条回答。它从一条 user 消息开始,中间可以包含多次工具调用和工具结果,直到模型给出最终回答;下一条 user 消息出现时,才算开始新的一轮。
例如,用户让模型读取一个文件时,一轮完整对话可能包含好几条消息:
text
user 请读取 config.ts
assistant 调用读取文件工具
tool 返回文件内容
assistant 根据文件内容回答
这四条消息属于同一轮对话。如果正好在 assistant 和 tool 中间切开,模型只能看到"调用了工具",却看不到工具返回的内容,这段记录就不完整了。
所以这里的核心原则是:优先保留一轮对话的完整信息,而不是强行凑够 2 万 Token。20_000 只是大致目标;为了不把用户提问、工具调用和工具结果从中间拆开,代码会适当调整分界线,因此最终保留的近期原文可能比 2 万 Token 多一点,也可能少一点。
整个模块的入口是 buildWorkingMemory()。它先读取 Session,并判断当前上下文有没有超过预算:
ts
async buildWorkingMemory(session: Session): Promise<Message[]> {
const history = session.getHistory();
const state = session.getCompaction();
const start = state?.compactedUntil ?? 0;
const pending = history.slice(start);
const current = this.withSummary(state?.summary, pending);
const usableTokens = this.maxContextTokens - this.reserveTokens;
const contextTokens = estimateContextTokens(
session,
history,
current,
);
if (contextTokens <= usableTokens) {
return current;
}
这里的 current 就是本轮准备发给模型的"工作记忆"。如果之前压缩过,它由"上一次摘要 + 尚未压缩的近期消息"组成;如果没有压缩过,它就是完整历史。
reserveTokens 要从上下文上限中提前扣掉,因为不能把整个窗口都塞满历史,还要给模型本轮回答留出空间。没有超过预算时,方法在这里直接返回,不会生成摘要。
如果超过预算,buildWorkingMemory() 才会真正进入摘要流程。这里所谓的"本地压缩",不是本地代码自己理解并改写聊天内容。本地代码只负责三件事:估算大小、切分新旧消息、调用大模型。真正的摘要文本仍然由大模型生成。
以 128K 上下文刚好达到临界点为例,整个过程大致是:
text
当前工作记忆约 112K
↓ 触发压缩
本地程序保留最近约 20K 原文
↓
剩余约 92K 较早内容
↓ 第一次调用模型
模型把较早内容生成一份摘要
↓
本地程序组成"新摘要 + 最近约 20K 原文"
↓ 第二次调用模型
模型根据新的工作记忆回答用户
这里的 92K 只是方便理解的近似值,来自 128K - 16K - 20K。实际切分时还要考虑上一次摘要、系统提示词和完整对话边界,所以不会刚好等于这个数字。
如果以前已经压缩过,第一次模型调用收到的也不是那些已经被压缩过的原始消息,而是"上一次摘要 + 这次新划入较早区域的消息"。模型会在旧摘要的基础上生成一份更新后的摘要。
因此,只有触发压缩的这一轮通常会调用两次模型。没有触发压缩时,buildWorkingMemory() 直接返回当前消息,Agent 只调用一次模型完成正常回答。
只有超过预算,才需要考虑压缩。不过,在看下面的代码前,要先分清三个动作:
text
估算 用"UTF-8 字节数 ÷ 4"粗略计算消息有多大,不会修改消息
切分 把消息分成"较早内容"和"近期内容"两组,也不会修改消息
压缩 让模型把较早内容生成一份更短的摘要
为什么需要"切分"?因为我们不希望把所有历史都变成摘要。最近的对话包含当前正在处理的代码、工具结果和用户要求,保留原文会更准确;只有较早的内容才适合变成摘要。
cutIndex 就是这两组消息之间的数组下标。例如:
text
pending = [消息0, 消息1, 消息2, 消息3, 消息4, 消息5]
cutIndex = 4
较早内容 = pending.slice(0, 4) = [消息0, 消息1, 消息2, 消息3]
近期原文 = pending.slice(4) = [消息4, 消息5]
它不是 Token 数,也不会截断消息内容,只是告诉程序"从哪一条消息开始保留原文"。现在再写寻找分界线的代码:
ts
const cutIndex = this.findCutIndex(pending);
// 没找到安全的分界线时,暂不生成摘要,只截短过长的工具输出。
if (cutIndex <= 0) {
return this.withSummary(
state?.summary,
truncateLargeToolOutputs(pending),
);
}
findCutIndex() 返回 0,表示当前没有找到一个既能保留近期原文、又不会拆散完整对话的位置。例如,当前可能只有一轮很长的工具调用,没有下一轮 user 消息可以作为分界线。
遇到这种情况,当前版本先不生成摘要,只调用 truncateLargeToolOutputs() 检查工具结果。如果某条工具结果超过 8,000 个字符,就只保留开头和结尾。这个"截短工具输出"和前面的 Token 估算不是一回事:Token 估算只计算大小,而这里才会真正缩短消息内容。
找到安全的分界线后,先在终端告诉用户压缩已经开始:
ts
console.log(
`上下文接近上限(约 ${contextTokens} / ${usableTokens} Token),正在压缩较早消息...`,
);
这条日志只在真正准备调用模型生成摘要时出现。如果只是普通对话,或者当前没有安全的切分位置,就不会误报"正在压缩"。
找到切分位置后,先把消息真正分成两组:
ts
const older = truncateLargeToolOutputs(pending.slice(0, cutIndex));
const recent = truncateLargeToolOutputs(pending.slice(cutIndex));
现在两个变量分别保存:
text
older 分界线前面的较早消息,接下来要生成摘要
recent 分界线后面的近期消息,继续保留原文
两组消息都会经过 truncateLargeToolOutputs(),只是为了防止其中某一条工具结果特别长。它不会删除 Session 中的原始历史。
接着拼出交给摘要模型的内容:
ts
const summaryInput = [
state?.summary ? `# 上一次摘要\n${state.summary}` : "",
`# 新增历史\n${JSON.stringify(older, null, 2)}`,
]
.filter(Boolean)
.join("\n\n");
第一次压缩时还没有 state.summary,所以输入里只有 older 这批较早消息。第二次压缩时,输入会同时包含:
text
# 上一次摘要
已经压缩过的更早内容
# 新增历史
这一次准备加入摘要的 older 消息
JSON.stringify(older, null, 2) 只是把消息数组转换成模型可以阅读的文本。.filter(Boolean) 会移除第一次压缩时那个空的"上一次摘要",.join("\n\n") 再把剩余部分用两个换行连接起来。
准备好 summaryInput 后,再调用模型生成新摘要:
ts
// 摘要请求只取 message,不把 usage 保存到主 Session。
const completion = await this.client.completeWithUsage(
[
{ role: "system", content: SUMMARY_PROMPT },
{ role: "user", content: summaryInput },
],
[],
);
这里的 system 消息告诉模型摘要必须保留哪些信息,user 消息则放入刚才拼好的真实历史。最后一个参数传 [],表示这次只需要模型总结文本,不允许它调用文件或命令工具。
这次请求返回的 Token 用量属于"生成摘要"这个临时请求,不能当成主会话的上下文用量,所以这里只取 message。
接着检查模型有没有成功返回摘要:
ts
const summary = completion.message?.content?.trim() ?? "";
if (!summary) {
return this.withSummary(
state?.summary,
truncateLargeToolOutputs(pending),
);
}
如果 summary 是空字符串,说明本次压缩失败。此时不更新压缩进度,继续使用原来的摘要和全部尚未压缩的消息,避免把历史意外丢掉。
摘要成功后,保存结果并返回新的工作记忆:
ts
session.saveCompaction(summary, start + cutIndex);
console.log("✓ 上下文压缩完成,近期消息继续保留原文。\n");
return this.withSummary(summary, recent);
}
这里为什么保存 start + cutIndex,而不是只保存 cutIndex?因为 cutIndex 是相对于 pending 的位置,而 start 是 pending 在完整 history 中的起点。
例如,上一次已经压缩到 history[6],所以 start = 6。这次又从 pending 中压缩了前 2 条,也就是 cutIndex = 2,那么新的位置就是:
text
compactedUntil = 6 + 2 = 8
最后,withSummary(summary, recent) 返回"新摘要 + 近期原文"。Session 中的完整 history 仍然没有被删除,改变的只是本轮真正发给模型的工作记忆。
接下来看看 findCutIndex() 怎样决定从哪里切开历史:
ts
private findCutIndex(messages: Message[]): number {
let recentTokens = 0;
for (let index = messages.length - 1; index >= 0; index -= 1) {
recentTokens += estimateMessageTokens(messages[index]);
// 小于2万,继续
if (recentTokens < this.keepRecentTokens) continue;
// 从普通 user 消息开始保留,避免把 assistant 工具调用和 tool 结果拆开。
for (let cut = index; cut < messages.length; cut += 1) {
if (messages[cut].role === "user") {
return cut;
}
}
}
return 0;
}
它先从后往前计算,找到"最近约 2 万 Token"对应的大致位置;然后再从这个位置向后寻找第一条 user 消息,把它作为近期原文的起点。
这样做是为了让保留下来的内容从一轮新的用户提问开始。例如:
text
较早内容,进入摘要
─────────────────────────────┐
user 请读取 config.ts │
assistant 调用读取工具 │ 同一轮对话,整体进入摘要
tool 返回文件内容 │
assistant 根据内容回答 │
─────────────────────────────┘
近期内容,保留原文
─────────────────────────────┐
user 请继续修改配置 │ 从下一轮用户提问开始
assistant ... │
─────────────────────────────┘
如果直接按照 Token 数从中间切开,近期消息可能只剩下一条 tool 结果,却没有前面对应的 assistant 工具调用;也可能只保留了工具调用,却丢掉工具结果。模型下一轮不仅难以理解发生了什么,OpenAI 兼容接口还可能因为工具消息顺序不完整而直接报错。
所以 findCutIndex() 的核心不是追求正好保留 2 万 Token,而是找到下一条 user 消息,让工具调用、工具结果和最终回答保持在同一边,保证一轮对话的语义和消息结构都是完整的。
再看 withSummary() 怎样组装最终的工作记忆:
ts
private withSummary(
summary: string | undefined,
messages: Message[],
): Message[] {
if (!summary) return messages;
const summaryMessage: Message = {
role: "user",
content: `[系统生成的较早会话摘要]\n${summary}`,
};
return [summaryMessage, ...messages];
}
}
这个方法把摘要包装成一条消息,放在近期原文前面。Agent 最终拿到的就是"较早历史摘要 + 近期消息原文"。
Compactor 类后面的几个函数负责计算 Token。先看它怎样优先使用接口返回的真实用量:
ts
function estimateContextTokens(
session: Session,
history: Message[],
current: Message[],
): number {
const usage = session.getUsage();
// 还没有真实 usage 时,估算当前工作记忆。
if (usage === undefined) {
return estimateTokens(current);
}
// usage 覆盖之前的消息,只估算它之后新增的部分。
const trailingMessages = history.slice(usage.historyLength);
return usage.totalTokens + estimateTokens(trailingMessages);
}
有 usage 时,只估算它之后新增加的消息;还没有真实 usage 时,才估算当前全部工作记忆。这就是前面保存 historyLength 的用途。
具体的估算方法如下:
ts
function estimateTokens(messages: Message[]): number {
return messages.reduce(
(total, message) => total + estimateMessageTokens(message),
0,
);
}
function estimateMessageTokens(message: Message): number {
const bytes = Buffer.byteLength(JSON.stringify(message), "utf8");
// 只用于估算尚未包含在 API usage 中的新消息。
return Math.ceil(bytes / 4);
}
这里把消息转成 JSON,再按照 UTF-8 字节数粗略换算 Token。它只负责估算接口尚未统计的新消息,不追求和每一家模型的 tokenizer 完全一致。
最后看看特别长的工具输出是怎样处理的:
ts
function truncateLargeToolOutputs(messages: Message[]): Message[] {
return messages.map((message) => {
if (message.role !== "tool" || typeof message.content !== "string") {
return message;
}
const content = message.content;
if (content.length <= 8_000) {
return message;
}
return {
...message,
content:
`${content.slice(0, 4_000)}\n\n` +
"...[中间工具输出已截断]...\n\n" +
content.slice(-4_000),
};
});
}
工具读取文件或执行命令时,单条结果可能比整段聊天还长。这里保留输出的开头和结尾,省略中间部分,避免一条工具消息直接占满上下文。
到这里,Compactor 的执行过程就拆解完了。它不会修改 Session 保存的完整历史,只负责在每次请求前生成一份更短的工作记忆。
这一步借鉴了 Pi Agent 的混合计数思路:优先相信模型接口返回的 usage,只估算 usage 之后新增的消息。这样不需要为每家模型引入不同的 tokenizer,也比从头估算全部历史更可靠。
maxContextTokens 现在来自 provider.json.contextWindow,不再写死。JSON.stringify 后按字节数除以 4,只是给新增消息使用的便宜近似。
把 Compactor 接进 src/agent.ts
前面的 Compactor 已经可以生成工作记忆,现在直接用下面的完整代码替换 src/agent.ts:
ts
import type OpenAI from "openai";
import { ChatClient } from "./chat.ts";
import { Compactor } from "./context/compactor.ts";
import type { Session } from "./context/session.ts";
import { Registry } from "./tools/registry.ts";
const MAX_STEPS = 8;
export class Agent {
private readonly compactor: Compactor;
constructor(
private readonly client: ChatClient,
private readonly registry: Registry,
private readonly session: Session,
) {
this.compactor = new Compactor(
client,
client.getContextWindow(),
);
}
async run(prompt: string): Promise<string> {
this.session.append({
role: "user",
content: prompt,
});
for (let step = 1; step <= MAX_STEPS; step += 1) {
const memory = await this.compactor.buildWorkingMemory(this.session);
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{
role: "system",
content:
"你是 power-code,一个研发助手。请优先读取真实文件;修改后主动运行命令验证结果。请使用中文回答。",
},
...memory,
];
const completion = await this.client.completeWithUsage(
messages,
this.registry.getDefinitions(),
);
const message = completion.message;
if (!message) {
throw new Error("模型没有返回消息。");
}
this.session.append(message);
if (
completion.totalTokens !== undefined &&
completion.totalTokens > 0
) {
this.session.saveUsage(completion.totalTokens);
}
const toolCalls = message.tool_calls ?? [];
if (toolCalls.length === 0) {
return message.content ?? "模型没有返回文本内容。";
}
for (const toolCall of toolCalls) {
if (toolCall.type !== "function") {
throw new Error(`暂时不支持工具类型:${toolCall.type}`);
}
const name = toolCall.function.name;
console.log(`第 ${step} 轮:AI 调用 ${name}`);
let result: string;
try {
result = await this.registry.execute(
name,
toolCall.function.arguments,
);
console.log(`✓ ${name} 执行完成\n`);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
result = `工具执行失败:${reason}`;
console.log(`✗ ${result}\n`);
}
this.session.append({
role: "tool",
tool_call_id: toolCall.id,
content: result,
});
}
}
throw new Error(`执行超过 ${MAX_STEPS} 轮,已停止。`);
}
}
完整代码先放在这里,方便直接替换。下面再逐步看这一版 Agent 和前一版相比改了什么;工具执行部分没有改变,重点是工作记忆和 Token 用量怎样接进 Agent Loop。
先看新增的导入:
ts
import { Compactor } from "./context/compactor.ts";
Agent 还需要保存一个 Compactor 实例:
ts
private readonly compactor: Compactor;
构造函数使用当前模型的上下文上限创建它:
ts
constructor(
private readonly client: ChatClient,
private readonly registry: Registry,
private readonly session: Session,
) {
this.compactor = new Compactor(
client,
client.getContextWindow(),
);
}
进入 Agent Loop 后,原来是直接读取 Session 的完整历史:
ts
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{
role: "system",
content: "...",
},
...this.session.getHistory(),
];
现在改为先让 Compactor 构建本轮工作记忆:
ts
const memory = await this.compactor.buildWorkingMemory(this.session);
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{
role: "system",
content:
"你是 power-code,一个研发助手。请优先读取真实文件;修改后主动运行命令验证结果。请使用中文回答。",
},
...memory,
];
模型调用也要换成 completeWithUsage(),并在保存回复后记录这次真实用量:
ts
const completion = await this.client.completeWithUsage(
messages,
this.registry.getDefinitions(),
);
const message = completion.message;
if (!message) {
throw new Error("模型没有返回消息。");
}
this.session.append(message);
if (
completion.totalTokens !== undefined &&
completion.totalTokens > 0
) {
this.session.saveUsage(completion.totalTokens);
}
这里一定要先 append(message),再 saveUsage()。因为 historyLength 要记录这次 usage 已经覆盖到哪条消息。
工具结果仍然照常写入 Session:
ts
this.session.append({
role: "tool",
tool_call_id: toolCall.id,
content: result,
});
它们决定了下一次压缩时 Session 里到底有什么内容。
现在构建:
bash
npm run build
如果构建通过,逻辑关系就变成了:
text
Session 保存完整历史
↓
Compactor 选择工作记忆
↓
Agent 加上 system prompt 请求模型
↓
模型回复和真实 usage 写回 Session
↓
新增工具结果暂时估算 Token
怎样观察上下文压缩
正常的小任务可能还没有长到触发 Compactor。如果只让读者在终端里手工聊几十轮,不但麻烦,而且每个人触发压缩的时机都不一样。
这里增加一个专门的演示脚本。它会自动创建一个大文件,把文件内容拆成多轮完整消息放进 Session,然后触发一次真实的摘要请求。摘要完成后,它还会再调用一次模型,演示 Agent 怎样使用"摘要 + 近期原文"继续回答。
新建 src/demo-compaction.ts
ts
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { ChatClient } from "./chat.ts";
import { loadConfig } from "./config.ts";
import { Compactor } from "./context/compactor.ts";
import { Session } from "./context/session.ts";
const DEMO_DIR = resolve("workspace/context-demo");
const DEMO_FILE = resolve(DEMO_DIR, "large-context.txt");
const BEFORE_FILE = resolve(DEMO_DIR, "context-before.json");
const SUMMARY_FILE = resolve(DEMO_DIR, "compressed-summary.md");
const AFTER_FILE = resolve(DEMO_DIR, "context-after.json");
function formatBytes(bytes: number): string {
return `${(bytes / 1024).toFixed(1)} KB`;
}
async function main() {
const config = await loadConfig();
const client = new ChatClient(config);
const session = new Session();
const blocks = Array.from({ length: 14 }, (_, index) => {
const title = `# 演示片段 ${index + 1}`;
const content = `context demo block ${index + 1} `.repeat(300);
return `${title}\n${content}`;
});
await mkdir(DEMO_DIR, { recursive: true });
await writeFile(DEMO_FILE, blocks.join("\n\n"), "utf8");
const largeContent = await readFile(DEMO_FILE, "utf8");
const sections = largeContent.split("\n\n");
for (const [index, section] of sections.entries()) {
const toolCallId = `demo-read-${index + 1}`;
session.append(
{
role: "user",
content: `请读取演示文件的第 ${index + 1} 段。`,
},
{
role: "assistant",
content: null,
tool_calls: [
{
id: toolCallId,
type: "function",
function: {
name: "read_file",
arguments: JSON.stringify({ path: DEMO_FILE }),
},
},
],
},
{
role: "tool",
tool_call_id: toolCallId,
content: section,
},
{
role: "assistant",
content: `已经读取第 ${index + 1} 段。`,
},
);
}
session.append({
role: "user",
content: "请用一句话说明刚才读取的演示文件主要包含什么。",
});
console.log(`已创建演示文件:${DEMO_FILE}`);
console.log(`已构造 ${sections.length} 轮历史消息。\n`);
const beforeContext = JSON.stringify(session.getHistory(), null, 2);
await writeFile(BEFORE_FILE, beforeContext, "utf8");
// 这里使用较小的演示预算,让压缩可以稳定触发。
// 不会修改 provider.json 中模型真实的 contextWindow。
const compactor = new Compactor(
client,
16_000,
4_000,
4_000,
);
const memory = await compactor.buildWorkingMemory(session);
const state = session.getCompaction();
if (!state?.summary) {
throw new Error("没有触发上下文压缩,请增加演示文件内容后重试。");
}
const afterContext = JSON.stringify(memory, null, 2);
await Promise.all([
writeFile(SUMMARY_FILE, state.summary, "utf8"),
writeFile(AFTER_FILE, afterContext, "utf8"),
]);
console.log(`完整历史:${session.getHistory().length} 条消息`);
console.log(`本轮工作记忆:${memory.length} 条消息`);
console.log(`已经压缩到 history[${state.compactedUntil}]。\n`);
console.log("压缩前后文件:");
console.log(
`压缩前 ${formatBytes(Buffer.byteLength(beforeContext, "utf8"))} ${BEFORE_FILE}`,
);
console.log(
`摘要 ${formatBytes(Buffer.byteLength(state.summary, "utf8"))} ${SUMMARY_FILE}`,
);
console.log(
`压缩后 ${formatBytes(Buffer.byteLength(afterContext, "utf8"))} ${AFTER_FILE}\n`,
);
const completion = await client.completeWithUsage(
[
{
role: "system",
content: "你是一个用于验证上下文压缩的助手,请使用中文简短回答。",
},
...memory,
],
[],
);
console.log(`AI:${completion.message?.content ?? "没有返回内容"}`);
}
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`演示失败:${message}`);
process.exit(1);
});
这个脚本没有让 Agent 真正执行 14 次 read_file,而是在本地把大文件拆成 14 轮已经完成的工具调用记录,再交给真实的 Session 和 Compactor。这样既能保留"用户提问 → assistant 调工具 → tool 返回 → assistant 回答"的完整结构,也只需要两次真实模型请求:一次生成摘要,一次使用摘要继续回答。
为了让前后变化可以直接打开查看,脚本还会在 workspace/context-demo 中生成:
text
large-context.txt 原始演示大文件,不会被 Compactor 修改
context-before.json 压缩前的完整 Session 消息
compressed-summary.md 大模型生成的摘要
context-after.json 摘要 + 近期原文,也就是压缩后的工作记忆
真正应该比较的是 context-before.json 和 context-after.json,因为 Compactor 压缩的是聊天上下文,不是 large-context.txt 这个源文件。compressed-summary.md 则方便单独检查模型究竟保留了哪些关键信息。
这些文件只是演示脚本为了方便观察而额外导出的。正常运行 powercode 时,Compactor 仍然只把摘要保存在 Session 内存中,不会自动往用户项目里写调试文件。
演示参数 16_000 / 4_000 / 4_000 只用于让效果更容易出现,不会修改 provider.json,也不会冒充模型真实的上下文上限。正式运行 powercode 时,Agent 仍然使用模型真实的 contextWindow 和 Compactor 的正式默认值。
现在构建并运行:
bash
npm run build
node dist/demo-compaction.js
终端会看到类似下面的输出:

具体的 Token 数、消息数量和摘要位置可能略有差异,但只要看到"正在压缩"和"压缩完成",就说明摘要流程已经真实执行。
脚本生成的文件都只位于 workspace/context-demo,不会修改练习项目的源码。演示完成后,不再需要它们时可以直接删除这个演示目录。
正常运行时什么时候会压缩
演示结束后,再回到正式配置。实际压缩阈值是:
text
provider.json 中的 contextWindow
- reserveTokens 预留的 16,384
= 触发压缩的阈值
例如,contextWindow = 128000 时,工作记忆超过约 111616 Token 就会触发压缩。正常的小任务很难达到这个长度,这是正常现象,不要为了尽快看到压缩效果,故意把真实的模型上限填小。
触发压缩后,Agent 会额外发起一次不带工具的摘要请求。你可以把它理解成会议进行到一半时,先整理一页交接纪要,再带着纪要继续讨论:
text
较早消息
↓ 第一次调用模型
生成结构化摘要
↓
摘要 + 最近约 20K 原文
↓ 第二次调用模型
继续回答当前问题
这份摘要至少要保留:
text
用户最终想完成什么
哪些约束不能违反
已经做完了哪些事情
读过或修改过哪些文件
当前遇到了什么问题
下一步准备做什么
摘要生成以后,最近约 20K Token 仍然保留原文。这样模型既能从摘要里知道前面的任务背景,也能看到最近的代码、工具结果和用户要求。
需要再次强调:压缩只改变本轮发送给模型的工作记忆,不会删除 Session.history。如果以后需要审计、调试或恢复完整聊天记录,还可以继续把历史写入 JSONL、SQLite 或其他持久化存储;这不属于这一篇的最小实现。
几个容易踩的坑
1. 把 Session 当成永久记忆
当前 Session 只存在于 Node.js 进程的内存中:
text
进程还在:Session 还在
进程退出:Session 消失
这一篇解决的是同一进程内的连续对话,不是跨进程恢复。因此,不要看到 Session 这个名字,就误以为它已经像数据库一样永久保存了会话。
跨进程恢复至少还需要一种持久化方式,例如保存聊天记录,或者把真正重要的任务状态写进工作区文件。下一篇会继续处理这个问题。
2. 把摘要当成绝对事实
摘要由模型生成,可能漏掉一个数字、一个限制条件,甚至把"讨论过的方案"误写成"已经决定的方案"。所以摘要适合缩短上下文,却不应该被当成绝对准确的任务账本。
这也是为什么 Compactor 不会把所有消息都变成摘要,而是继续保留最近一段原文。真正不能丢的验收标准和任务进度,后续还应该写进外部文件。
3. 把 contextWindow 填成统一默认值
不同模型的上下文上限不同。provider.json.contextWindow 必须填写当前模型的真实规格,不能为了更容易观察压缩而故意填错。
填得太大,程序可能还没有来得及压缩,请求就已经超过模型限制;填得太小,又会让 Agent 过早生成摘要,增加请求次数和信息损失。
4. 把 Token 估算当成精确结果
usage.total_tokens 是接口返回的真实用量,但"UTF-8 字节数 ÷ 4"只是对新增消息的粗略估算。不同模型、不同语言和不同消息结构的实际 Token 数都会有差异。
这套混合计算的目标是用较低成本判断"是否接近上限",不是实现一个与服务商 tokenizer 完全一致的计数器。
5. 以为截短工具输出会删除 Session 历史
truncateLargeToolOutputs() 处理的是 getHistory() 返回的副本,只会缩短本轮工作记忆中的超长工具结果。Session 内部保存的原始消息仍然存在,不会被这个函数改写。
到这里,我们给 Agent 增加了什么
这一篇没有让模型突然获得更大的上下文窗口,也没有引入一个神秘的"记忆模型"。我们只是把消息管理拆成了几个职责明确的部分:
text
Session
保存同一进程里的完整消息
终端交互循环
让用户可以连续输入和追问
UsageSnapshot
记录最近一次 API 请求真实覆盖的 Token 和消息位置
Compactor
判断什么时候需要压缩,并生成"摘要 + 近期原文"
工作记忆
决定这一轮真正发送给模型的消息
于是,整个流程变成:
text
用户继续追问
↓
消息追加到 Session
↓
Compactor 根据 usage 判断上下文是否接近上限
├─ 没达到:继续使用当前工作记忆
└─ 达到了:先让模型生成摘要,再保留近期原文
↓
Agent 使用整理后的工作记忆回答
现在的 powercode 已经可以像 Claude Code 或 Codex CLI 一样,在一次启动中持续对话;上下文变长后,也不会永远把全部历史原样塞给模型。更重要的是,我们已经把"完整历史"和"本轮发给模型的内容"分开了,后面继续扩展持久化、任务规划或不同的压缩策略时,不需要重新推翻 Agent Loop。
但进程一旦退出,内存里的 Session 仍然会消失。下一篇我们再解决另一类问题:把目标、方案和执行进度写进 PLAN.md、TODO.md,并加入一个简化版 Plan Mode,让新进程也能找到任务做到哪里。