← 博客书房

Agent 实战:从 Dify 到 LangGraph·Context Engineering:上下文工程

实战25 分钟

Context Engineering:上下文工程

2026 年 Agent 工程的核心技能:上下文压缩策略、Token 预算精细管理、长任务状态管理。参考 easy-agent 的 compaction + budget 实现。

2026 年 Agent 工程的核心技能:不是给模型更多上下文,而是给模型对的上下文。

为什么需要上下文工程

你的 Agent 跑了 30 分钟,调了 50 次工具,消息数组已经膨胀到 15 万 token。接下来会发生什么?

  1. API 报错:超过模型的上下文窗口上限(如 200K token)
  2. 成本爆炸:每次调用都要付 15 万 token 的输入费用
  3. 质量下降:lost in the middle——关键信息被淹没在中间,模型找不到

上下文工程解决这三个问题。它不是一门玄学,是一套可量化的工程实践。

Token 预算:先搞清楚你有多少钱

Token 预算分配(以 200K 窗口为例) 模型上下文窗口:200,000 tokens 有效窗口:180,000(预留 20K 给模型回复) 回复预留 自动压缩触发线:167,000(再留 13K 缓冲) 缓冲区(不给用户)

easy-agent Step 11 的实现:

const MODEL_CONTEXT_WINDOW = 200_000;
const AUTOCOMPACT_BUFFER_TOKENS = 13_000;

function buildTokenBudgetSnapshot(messages) {
  const estimatedTokens = tokenCountWithEstimation(messages);
  const effectiveWindow = MODEL_CONTEXT_WINDOW - 20_000;  // 留给回复
  return {
    estimatedTokens,
    autoCompactThreshold: effectiveWindow - AUTOCOMPACT_BUFFER_TOKENS,
  };
}

三层扣减:总窗口 → 留回复空间 → 留压缩缓冲。剩下的才是你能「花」的。

Token 估算:不精确但够用

你不可能每次都调 API 来查精确 token 数(那本身就要花钱)。easy-agent 用字符数启发式:

const TEXT_CHARS_PER_TOKEN = 4;     // 英文约 4 字符 = 1 token
const JSON_CHARS_PER_TOKEN = 2;     // JSON 结构更密,约 2 字符 = 1 token

function roughTokenCount(content) {
  return Math.max(1, Math.round(content.length / TEXT_CHARS_PER_TOKEN));
}

然后混合锚定:拿最近一次 API 返回的 usage.input_tokens(精确值)作为锚点,只估算锚点之后新增的消息:

function tokenCountWithEstimation(messages, { usage, usageAnchorIndex }) {
  if (usage && usageAnchorIndex !== undefined) {
    const knownTokens = usage.input_tokens;  // 精确
    const suffix = messages.slice(usageAnchorIndex + 1);
    return knownTokens + estimateMessagesTokens(suffix);  // 估算
  }
  return estimateMessagesTokens(messages);  // 全估算
}

压缩策略:两级火箭

+ + 两级压缩策略 消息数组 越滚越大 超过阈值? 167K tokens 是 第一级:微压缩 清空旧工具结果 → 替换为占位符 零成本 · 不调 API 还超? 再查一次 第二级:全压缩 AI 生成对话摘要 + 保留最近 8 条 花一次 API 调用 · 换 90% 空间 效果对比 压缩前:150K tokens → 压缩后:8K tokens(-95%) 微压缩免费拿到 30-50% 减量 · 全压缩再拿到 90%+

第一级:微压缩(零成本)

把旧的工具结果替换为占位符——不调 API,纯本地操作:

const CLEARED_PLACEHOLDER = "[Old tool result content cleared]";

function microCompactMessage(message) {
  return {
    ...message,
    content: message.content.map(block => {
      if (block.type === "tool_result" && isOldTool(block)) {
        return { ...block, content: CLEARED_PLACEHOLDER };
      }
      return block;
    }),
  };
}

只清旧工具结果(Read/Grep/Bash),不清用户消息和模型回答。模型已经看过这些工具结果了,后续对话不需要原始内容。

第二级:全压缩(花一次 API 调用)

微压缩不够时,让模型自己总结历史:

async function compactMessages(messages, callModel) {
  // 1. 先微压缩
  const microCompacted = microCompactMessages(messages);

  // 2. 让 AI 总结前半段
  const summary = await callModel(
    "请详细总结这段对话:用户请求、技术决策、文件名、错误、待办事项…",
    microCompacted
  );

  // 3. 保留最近 8 条消息(原文不压缩)
  const tail = microCompacted.slice(-8);

  // 4. 组装新数组:摘要 + 最近对话
  return [
    { role: "user", content: "以下是之前对话的摘要:\n" + summary },
    { role: "assistant", content: "[CompactBoundary]" },
    ...tail,
  ];
}

碎开来说几个关键细节:

为什么保留最近 8 条? 模型对最近上下文的利用率最高(开头结尾效应)。最近几轮对话是当前任务的直接上下文,压掉会丢失正在进行的工作状态。

为什么用 AI 总结而不是规则截断? 规则截断(只留最近 N 条)会丢失重要信息:用户最初的目标、关键决策、已尝试的方案。AI 总结能提取这些语义信息。

CompactBoundary 标记有什么用? 告诉模型「这里发生过压缩」——模型看到这个标记就知道前面的内容是摘要不是完整对话,不会误以为信息被遗漏。

tool_use / tool_result 配对保护

压缩时最容易犯的错:把 tool_use 和 tool_result 拆散了。API 要求这两个必须成对出现。

function findSafeTailStart(messages, desiredCount) {
  let start = messages.length - desiredCount;
  while (start > 0) {
    const tail = messages.slice(start);
    // 检查是否有悬空的 tool_result(没有对应的 tool_use)
    const hasDangling = checkForDanglingToolResults(tail);
    if (!hasDangling) return start;
    start--;  // 往前多保留一条,直到配对完整
  }
  return 0;
}

本章要点

  1. 上下文工程 = 在有限的 token 窗口里,给模型最相关的信息
  2. Token 预算三层扣减:总窗口 → 回复预留 → 压缩缓冲
  3. 混合估算:API 精确值做锚点 + 字符启发式估增量
  4. 两级压缩:微压缩(免费,清旧工具结果)→ 全压缩(AI 总结 + 保留最近 8 条)
  5. tool_use / tool_result 必须成对——压缩时不能拆散

→ 下一章:RAG 深水区