Skip to content

第 9 章 · transformContext 上下文变换

本章目标:掌握消息处理流水线的完整顺序,用 transformContext 实现历史截断与上下文压缩策略。

9.1 完整流水线

每次 LLM 调用前,消息经过两道加工:

text
AgentMessage[]


transformContext(messages, signal)   ← 异步、可截断/压缩/注入
      │  返回 AgentMessage[](应用层形态)

convertToLlm(messages)               ← 过滤自定义类型、转成标准格式
      │  返回 Message[](LLM 层形态)

    LLM 请求

两者职责不同:transformContext 管「给模型看多少」(量的问题),convertToLlm 管「以什么形态看」(形的问题)。

9.2 基础用法:保留最近 N 轮

typescript
const agent = new Agent({
  streamFn: models.streamSimple.bind(models),
  initialState: { systemPrompt: "...", model },
  // 异步签名,可接收 AbortSignal
  transformContext: async (messages, signal) => {
    const MAX = 40; // 保留最近 40 条
    if (messages.length <= MAX) return messages;

    // 简单滑动窗口:丢掉最老的消息
    // 注意:toolResult 不能脱离其对应的 assistant 工具调用单独存在,
    // 截断点要避开「工具对」的中间位置
    return messages.slice(-MAX);
  },
});

截断的坑:工具对完整性

一条 assistant 消息可能包含 toolCall,紧随其后必有对应 toolResult。若从中间切断,LLM 会收到「孤立的工具结果」而报错。安全做法是按回合边界(user 消息处)切割。

9.3 按回合边界的安全截断

typescript
// 以 user 消息为界分组,再保留最近 N 个完整回合
function truncateByTurns(messages, maxTurns = 10) {
  // 找到每条 user 消息的下标作为回合起点
  const starts = messages
    .map((m, i) => (m.role === "user" ? i : -1))
    .filter((i) => i >= 0);

  if (starts.length <= maxTurns) return messages;
  const cutFrom = starts[starts.length - maxTurns];
  return messages.slice(cutFrom);
}

const agent2 = new Agent({
  transformContext: async (msgs) => truncateByTurns(msgs, 8),
  initialState: { systemPrompt: "...", model },
  streamFn: models.streamSimple.bind(models),
});

9.4 进阶:压缩(Compaction)策略

比丢弃更聪明的是摘要替换——把老对话压成一段总结:

typescript
// 思路示意:超过阈值时,把老回合交给便宜模型压缩成摘要
transformContext: async (messages) => {
  if (estimateTokens(messages) < 50_000) return messages;

  const [oldPart, recentPart] =
    splitAtTurnBoundary(messages, keepRecentTurns = 6);

  // 用小模型生成摘要(复用同一个 models 集合)
  const summaryMsg = await models.completeSimple(cheapModel, {
    systemPrompt: "把以下对话压缩成要点摘要,保留关键事实与未完成任务。",
    messages: toLlmShape(oldPart),
  });

  // 摘要以 user 消息形式注入开头
  return [
    {
      role: "user",
      content: `[历史摘要] ${summaryMsg.content[0].text}`,
      timestamp: Date.now(),
    },
    ...recentPart,
  ];
},

生产级实现还应缓存摘要结果(同一批老消息只压一次),并配合 shouldStopAfterTurn 钩子在回合间隙做压缩决策。

9.5 其他用途:注入外部上下文

typescript
// 除了裁剪,还能注入:比如实时检索到的资料
transformContext: async (messages, signal) => {
  const lastUser = [...messages].reverse().find((m) => m.role === "user");
  const docs = await retrieve(lastUser?.content ?? "", signal); // RAG 检索

  return [
    ...messages,
    {
      role: "user",
      content: `[参考资料]\n${docs}`,
      timestamp: Date.now(),
    },
  ];
};

9.6 本章小结

  • 流水线顺序固定:transformContext → convertToLlm → LLM
  • transformContext 解决「量」,convertToLlm 解决「形」;
  • 截断必须在回合边界进行,避免拆散 assistant(toolCall)+toolResult 对;
  • 压缩优于丢弃;配合 shouldStopAfterTurn 可在回合间隙从容执行;
  • 注入 RAG 资料、动态系统上下文也是它的合法用途。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. transformContext 与 convertToLlm 的职责划分是?

2. 为什么简单按条数 slice 截断可能出问题?

3. 相比直接丢弃老消息,「压缩」策略的核心优势是?

4. transformContext 的函数签名是什么?

🛠️ 动手实践

  1. 实现「保留最近 8 回合 + 更早内容压缩为摘要」的组合策略,并用长对话实测 token 变化。
  2. 给你的压缩逻辑加缓存:同一段老消息第二次触发时直接复用已有摘要。
  3. 利用 transformContext 注入当前日期与用户时区,验证模型回答「今天几号」的正确性。

下一章第 10 章:API Key 从哪里来?深入认证解析与凭据管理。