不用LangChain:用 200 行代码手搓 Agent 对话记忆与上下文压缩

🚀 欢迎来到「不用框架,手搓 AI Agent」系列第五篇。

即使没有读过前面的文章,也可以直接从这一篇开始。你只需要知道:我们已经有了一个能调用工具、读取和修改文件、执行命令的最小 AI Agent。

不过,这个 Agent 还有一个明显的问题:每次处理新问题时,它都像第一次见到你,不记得前面聊过什么。即使我们把所有消息一直保 存在内存里,对话变长后,也迟早会塞满模型有限的上下文窗口。

这一篇,我们不换模型,也不引入 Agent 框架,而是从零给它补上两项基础能力:用会话记录保留对话历史,用上下文压缩控制长期对 话的长度。

最终,我们会得到一个可以在终端里连续追问,并在对话过长时自动整理早期内容的 Agent。

系列目录:

  1. 不用框架,手搓 AI Agent:(一)先让它跑起来
  2. 不用 LangChain,手搓 AI Agent:给大模型装上"手",让它自己读项目文件
  3. 原来 AI Agent 的核心循环这么简单:手搓一个 Agent Loop
  4. Claude Code 是怎么自己改代码的?答案藏在这 4 个工具里
  5. 本文: :不用LangChain:用 200 行代码手搓 Agent 对话记忆与上下文压缩
  6. 更多实战持续更新中......

先来"放个毒",看看经过本系列的打磨,我们最终会亲手搞出一个怎样的"完全体"

先看看当前版本为什么会"失忆"

第四篇的 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 },
  ];

  // 在这里循环调用模型和工具
}

messagesrun() 的局部变量。函数返回以后,它也就跟着消失了。

所以,下面两次启动之间没有任何记忆:

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 buildnpm start,但 Agent 的四个工具只操作 workspace,不会拿自己的 src 目录练手。后面的压缩演示脚本会把文件集中放进 workspace/context-demo,不会影响工作区里的其他内容。

本文会直接在这份主项目代码上继续开发,不需要切换到其他项目或副本。后面的步骤会依次加入会话记忆和上下文压缩,最终得到前面展示的连续交互效果。


第一步:把局部 messages 交给 Session

Session 到底是什么

先别把"记忆"想得太玄。

Session 就是一只装消息的文件夹:用户说的话、模型的回复、工具返回的结果,都按发生顺序放进去。

text 复制代码
Session
├── 用户问题
├── 模型的工具调用
├── 工具执行结果
├── 模型的下一次回复
└── 后续用户问题

这一步先只解决一个问题:同一个进程里,下一次调用 Agent 时还能取到上一次的消息。

它还不是永久记忆。进程退出后,内存里的 Session 也会消失;跨重启的任务状态,后面交给 PLAN.mdTODO.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_filewrite_fileedit_filebash 都只在工作区中执行。下一篇再把这个固定目录升级成可以通过 -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() 等你。当你输入 exitquit 时,break 会结束循环,readline.close() 再把终端输入关闭,程序就退出了。

现在直接运行 npm start 就会进入交互模式。程序回答完一个问题后不会退出,而是继续等待你输入下一个问题。

bash 复制代码
npm start

交互过程中输入 exitquit,才会结束程序并返回普通的终端命令行。

关键是这两行在循环外面:

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 的内容
→ 第一次构建日志
→ 修改后的文件内容
→ 第二次构建日志
→ 一大段失败堆栈
→ 用户补充的新要求

如果每次都把完整历史原样发给模型,会出现两个问题:

  1. 输入越来越长,成本和延迟越来越高;
  2. 一次大日志可能把真正重要的新问题挤出上下文。

最粗暴的做法是只保留最后 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),
    };
  });
}

完整代码先放在这里,是为了方便复制。下面先不用试图一次看懂全部实现,我们再按照它的执行顺序逐步拆开。

先看文件顶部引入的 ChatClientSession 和消息类型:

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_38420_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  根据文件内容回答

这四条消息属于同一轮对话。如果正好在 assistanttool 中间切开,模型只能看到"调用了工具",却看不到工具返回的内容,这段记录就不完整了。

所以这里的核心原则是:优先保留一轮对话的完整信息,而不是强行凑够 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 的位置,而 startpending 在完整 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 轮已经完成的工具调用记录,再交给真实的 SessionCompactor。这样既能保留"用户提问 → assistant 调工具 → tool 返回 → assistant 回答"的完整结构,也只需要两次真实模型请求:一次生成摘要,一次使用摘要继续回答。

为了让前后变化可以直接打开查看,脚本还会在 workspace/context-demo 中生成:

text 复制代码
large-context.txt       原始演示大文件,不会被 Compactor 修改
context-before.json     压缩前的完整 Session 消息
compressed-summary.md   大模型生成的摘要
context-after.json      摘要 + 近期原文,也就是压缩后的工作记忆

真正应该比较的是 context-before.jsoncontext-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.mdTODO.md,并加入一个简化版 Plan Mode,让新进程也能找到任务做到哪里。

相关推荐
LaughingZhu1 小时前
Product Hunt 每日热榜 | 2026-08-09
人工智能·深度学习·神经网络·搜索引擎·百度
粥里有勺糖1 小时前
Harness 学习笔记分享(Part 1)
面试·github·agent
狂师1 小时前
推荐一款开源 Skill:让 AI Agent 给你做一份"能改"的 PPT,支持 上千套模板!
人工智能·agent
阿图灵1 小时前
基于 LSTM 的中文电商评论情感分类:从数据处理到 91% 准确率实战
人工智能·深度学习·分类·nlp·lstm·情感分类
小虎AI生活1 小时前
WorkBuddy 短视频自动化全链路:从脚本到出镜的架构设计
ai编程
用户298698530141 小时前
HTML 转 Word 指南:新手入门教程
人工智能·后端·python
冬哥聊AI1 小时前
淘天一面:Prefix Caching 原理是什么?Agent 框架怎么保证不破坏缓存?
人工智能
agent8971 小时前
实战升级|SpringBoot WebSocket实现多轮对话AI流式问答(上下文记忆+自动重连+会话隔离)
人工智能·spring boot·websocket
染指11101 小时前
80.高级RAG-LLamaIndex实际应用-金融助手
人工智能·rag·llama_index·llamaindex