Agent开发进阶指南(第 2 章):Agent 运行全流程拆解、上下文窗口、流式输出、记忆系统与 Function Call 实战

本系列定位:零基础可上手、工程向、可落地实操的 AI Agent 进阶教程。拒绝空洞理论,只讲看得懂、画得出图、写得出代码、跑得出效果、能直接用到项目里的内容。


2.1 Agent 运行全流程拆解


1. 开篇导读

这章解决什么问题?

第 1 章我们搭了一个 "极简骨架",让你知道了 Agent 能查天气、做计算。但那个 Demo 距离真实企业项目还有不小的距离:

  • 输入是裸字符串,没有标准化处理。
  • 没有真正的对话记忆,第二轮就失忆。
  • 工具调用失败没有重试和兜底。
  • 大模型的决策过程被简化成了 if/else。
  • 各个环节耦合在一个 runAgent 函数里,没法扩展。

这一部分,我们要把这个骨架拆成五个独立模块 ,让你看清楚 Agent 从用户输入到最终输出的完整流水线

学完这部分你能掌握什么?

  • 理解 Agent 执行过程中的五个核心阶段:输入解析 → 思考决策 → 工具执行 → 观察回填 → 输出生成
  • 学会把 Agent 拆成可扩展的模块(Parser、Planner、Executor、Memory、OutputGenerator)。
  • 写出一个更接近生产环境的 Agent 引擎,支持多轮对话、工具错误重试、最大循环保护。

适合什么场景?

  • 前端接入 Agent 时,需要明确前后端交互边界。
  • 后端封装 Agent 服务时,需要把流程拆清楚才能写单测。
  • 多轮对话型 Agent(客服、销售助手、招聘助手)。
  • 需要调用多个工具串联的复杂任务 Agent。

2. 核心原理通俗讲解

2.1 把 Agent 想象成一个"智能餐厅后厨"

我们用开餐厅来类比 Agent 的完整流程:

Agent 模块 餐厅角色 职责
输入解析器 前台收银 听清楚顾客点什么,整理成标准订单
记忆模块 顾客档案本 记住老顾客忌口、上次点了什么
推理规划器 后厨主管 看订单,决定让哪个厨师做什么菜
工具执行器 厨师+灶台 真正动手做菜
观察回填 传菜员 把做好的菜端回来给主管看
输出生成器 服务员 把菜整理好,端给顾客

顾客(用户)只需要说"我要一个不加辣的宫保鸡丁",后面整个流水线自动运转。

2.2 Agent 全流程的五个阶段

真实 Agent 的执行过程可以拆成五个阶段,每个阶段都有明确的输入和输出:

text 复制代码
阶段 1:输入解析(Input Parsing)
   └─ 输入:用户的原始自然语言
   └─ 输出:标准化的内部消息 { role: "user", content: "..." }

阶段 2:记忆加载(Memory Loading)
   └─ 输入:当前消息 + 历史会话
   └─ 输出:完整上下文消息列表 messages[]

阶段 3:思考决策(Planning / Reasoning)
   └─ 输入:messages[] + 工具说明书
   └─ 输出:Decision(直接回答 or 调用某个工具)

阶段 4:工具执行与观察(Tool Execution & Observation)
   └─ 输入:Decision(工具名 + 参数)
   └─ 输出:Observation(工具执行结果)

阶段 5:输出生成(Output Generation)
   └─ 输入:Observation + 历史上下文
   └─ 输出:给用户的最终回复

注意:阶段 3 和阶段 4 可能循环多次。如果主管发现"要宫保鸡丁需要先炸花生",就会先让厨师炸花生,再把花生倒回锅里继续炒。

2.3 为什么必须拆分模块?

很多初学者写 Agent 时,会把它写成一个大函数:

js 复制代码
async function agent(userInput) {
  const history = loadHistory();
  const llm = await callLLM(userInput, history, tools);
  if (llm.type === "tool") {
    const result = await callTool(llm.tool, llm.args);
    return callLLM(result, history);
  }
  return llm.content;
}

这种写法能跑,但有几个大坑:

  1. 没法单测:输入解析、工具执行、输出生成都混在一起。
  2. 不好扩展:加一个缓存层、日志层、限流层都很麻烦。
  3. 不好定位 Bug:一旦某个环节出错,你根本不知道问题出在哪个阶段。

拆成模块后,每个模块只做一件事,像 Lego 积木一样可以替换和扩展。

2.4 传统代码 VS Agent 流水线代码

维度 传统脚本式 Agent 模块化流水线 Agent
可维护性 差,全部耦在一起 好,阶段清晰
可测试性 难写单测 每个模块都能独立测
扩展性 改一处容易影响全局 插拔模块即可
生产适用性 Demo 级别 企业级
调试体验 找 Bug 靠猜 打日志能定位到具体阶段

3. 架构流程图 / 原理图

3.1 模块依赖关系

flowchart LR subgraph Agent[Agent 引擎] direction TB P[InputParser] M[Memory] PL[Planner] TE[ToolExecutor] OG[OutputGenerator] end User[用户] --> P P --> M M --> PL PL --> TE TE --> Tools[外部工具] Tools --> TE TE --> M PL --> OG OG --> User PL -.使用.-> LLM[大模型] TE -.读取.-> Reg[ToolRegistry]

3.2 多轮工具调用时序图

sequenceDiagram participant U as 用户 participant A as Agent 引擎 participant P as InputParser participant M as Memory participant PL as Planner participant TE as ToolExecutor participant T as 外部工具 U->>A: "查北京天气,再算一下温度加上 5 是多少" A->>P: 解析输入 P-->>A: { role: user, content } A->>M: 存入用户消息 M-->>A: messages[...] A->>PL: 请求决策 PL->>LLM: 发送系统提示+工具说明+messages LLM-->>PL: 调用 get_weather("北京") PL-->>A: Decision(tool) A->>TE: 执行工具 TE->>T: get_weather({city:"北京"}) T-->>TE: "27℃" TE-->>A: Observation A->>M: 存入 observation A->>PL: 再次请求决策 PL->>LLM: 发送更新后的 messages LLM-->>PL: 调用 calculate("27 + 5") PL-->>A: Decision(tool) A->>TE: 执行 calculate TE-->>A: "32" A->>M: 存入 observation A->>PL: 再次请求决策 LLM-->>PL: 直接回答 PL-->>A: Decision(answer) A->>OG: 生成最终回复 OG-->>U: "北京 27℃,加 5 后是 32℃。"

3.3 状态流转图

stateDiagram-v2 [*] --> IDLE: 用户输入 IDLE --> THINKING: 解析完成,开始决策 THINKING --> EXECUTING: LLM 决定调工具 THINKING --> RESPONDING: LLM 决定直接回答 EXECUTING --> THINKING: 工具结果回填,继续思考 EXECUTING --> FAILED: 工具执行异常 FAILED --> THINKING: 错误信息回填,尝试修复 RESPONDING --> [*]: 输出最终答案

4. 手把手实操代码(可直接运行)

下面是一个模块化的 Agent 引擎 Demo 。它把流程拆成了五个模块:Parser、Memory、Planner、ToolExecutor、OutputGenerator,并用一个 Agent 类来编排。

4.1 代码:agent-engine.ts

typescript 复制代码
// agent-engine.ts
// 模块化 Agent 引擎:输入 → 思考 → 工具 → 观察 → 输出

// ================== 类型定义 ==================

type Message = {
  role: "system" | "user" | "assistant" | "tool";
  content: string;
  toolName?: string; // tool 类型消息 optional 字段
};

type Tool = {
  name: string;
  description: string;
  parameters: Record<string, string>;
  execute: (args: any) => Promise<string>;
};

type AgentDecision =
  | { type: "answer"; content: string }
  | { type: "tool"; tool: string; args: Record<string, any> };

// ================== 1. 输入解析器 ==================

class InputParser {
  /**
   * 把用户原始输入,转换成标准内部消息。
   * 生产环境里这里可以做:敏感词过滤、输入截断、多语言检测等。
   */
  parse(rawInput: string): Message {
    return {
      role: "user",
      content: rawInput.trim(),
    };
  }
}

// ================== 2. 记忆模块 ==================

class Memory {
  private messages: Message[] = [];

  // 初始化时注入系统提示词
  constructor(systemPrompt: string) {
    this.messages.push({ role: "system", content: systemPrompt });
  }

  add(message: Message) {
    this.messages.push(message);
  }

  getMessages(): Message[] {
    return this.messages;
  }

  last(): Message | undefined {
    return this.messages[this.messages.length - 1];
  }
}

// ================== 3. 工具注册表与执行器 ==================

class ToolRegistry {
  private tools = new Map<string, Tool>();

  register(tool: Tool) {
    this.tools.set(tool.name, tool);
  }

  get(name: string): Tool | undefined {
    return this.tools.get(name);
  }

  list(): Tool[] {
    return Array.from(this.tools.values());
  }

  // 把工具列表转换成给 LLM 看的"说明书"
  describe(): string {
    return this.list()
      .map((t) => {
        const params = Object.entries(t.parameters)
          .map(([k, v]) => `${k}: ${v}`)
          .join(", ");
        return `- ${t.name}(${params}): ${t.description}`;
      })
      .join("\n");
  }
}

class ToolExecutor {
  constructor(private registry: ToolRegistry) {}

  async execute(decision: {
    tool: string;
    args: Record<string, any>;
  }): Promise<string> {
    const tool = this.registry.get(decision.tool);
    if (!tool) {
      return `错误:未找到工具 "${decision.tool}"`;
    }

    try {
      const result = await tool.execute(decision.args);
      return result;
    } catch (err: any) {
      return `工具执行出错:${err.message}`;
    }
  }
}

// ================== 4. 推理规划器(模拟 LLM) ==================

class Planner {
  constructor(private registry: ToolRegistry) {}

  /**
   * 真实项目中,这里应该把 messages + 工具说明拼成 prompt,
   * 调用 OpenAI / Claude 等接口,解析返回的 JSON。
   *
   * 为了 demo 可跑通,我们用 mock 逻辑模拟 LLM 的决策。
   */
  async decide(messages: Message[]): Promise<AgentDecision> {
    const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
    const input = lastUserMsg?.content ?? "";
    const lower = input.toLowerCase();

    // 工具说明书
    const toolDesc = this.registry.describe();

    // 模拟模型识别天气意图
    if (lower.includes("天气") && !lower.includes("工具返回")) {
      const match = input.match(/([\u4e00-\u9fa5]+?)(?:的|今天)?天气/);
      const city = match?.[1] ?? "北京";
      return { type: "tool", tool: "get_weather", args: { city } };
    }

    // 模拟模型识别计算意图
    if (lower.includes("计算") && !lower.includes("工具返回")) {
      const expr = input
        .replace(/.*计算\s*/, "")
        .replace(/[^0-9+\-*/().]/g, "");
      if (expr)
        return { type: "tool", tool: "calculate", args: { expression: expr } };
    }

    // 如果输入里带了"工具返回",说明是 observation 回填,模型直接组织语言
    if (input.includes("工具返回")) {
      return {
        type: "answer",
        content: this.mockSummarize(input),
      };
    }

    return {
      type: "answer",
      content: `我是一个 Agent 助手。当前可用工具:\n${toolDesc}\n你可以让我查天气或做计算。`,
    };
  }

  private mockSummarize(input: string): string {
    // 极其简化:根据工具结果编一句话
    if (input.includes("get_weather")) {
      const weather = input.match(/"([^"]+)"/)?.[1] ?? "未知";
      return `查询到的天气结果是:${weather}。`;
    }
    if (input.includes("calculate")) {
      const result = input.match(/结果是:"([^"]+)"/)?.[1] ?? "未知";
      return `计算结果是:${result}。`;
    }
    return "已收到工具结果。";
  }
}

// ================== 5. 输出生成器 ==================

class OutputGenerator {
  /**
   * 把 assistant 消息整理成最终输出。
   * 生产环境这里可能还要做:后处理、敏感词脱敏、格式化 JSON 等。
   */
  generate(content: string): string {
    return content;
  }
}

// ================== 6. Agent 引擎:编排以上所有模块 ==================

class Agent {
  private parser = new InputParser();
  private planner: Planner;
  private executor: ToolExecutor;
  private output = new OutputGenerator();

  constructor(
    private memory: Memory,
    private toolRegistry: ToolRegistry,
    private maxLoops = 3,
  ) {
    this.planner = new Planner(toolRegistry);
    this.executor = new ToolExecutor(toolRegistry);
  }

  async run(rawInput: string): Promise<string> {
    // 阶段 1:输入解析
    const userMessage = this.parser.parse(rawInput);
    this.memory.add(userMessage);

    // 阶段 2-4:思考 → 执行 → 观察 循环
    for (let i = 0; i < this.maxLoops; i++) {
      const messages = this.memory.getMessages();

      // 阶段 3:推理决策
      const decision = await this.planner.decide(messages);

      if (decision.type === "answer") {
        // 阶段 5:直接输出生成
        this.memory.add({ role: "assistant", content: decision.content });
        return this.output.generate(decision.content);
      }

      // 阶段 4:工具执行
      console.log(
        `🔧 [Loop ${i + 1}] 调用工具:${decision.tool}(${JSON.stringify(decision.args)})`,
      );
      const observation = await this.executor.execute(decision);
      console.log(`📥 [Loop ${i + 1}] 工具返回:${observation}`);

      // 把 observation 回填进记忆,形成新的上下文
      this.memory.add({
        role: "tool",
        content: `工具 "${decision.tool}" 返回结果是:"${observation}"。请根据这个结果回答用户。`,
        toolName: decision.tool,
      });
    }

    const timeoutMsg = "思考次数过多,暂时无法给出完整答案。";
    this.memory.add({ role: "assistant", content: timeoutMsg });
    return timeoutMsg;
  }
}

// ================== 7. 注册工具并运行 ==================

const toolRegistry = new ToolRegistry();

toolRegistry.register({
  name: "get_weather",
  description: "查询指定城市的当前天气",
  parameters: { city: "城市名,如北京、上海" },
  execute: async ({ city }) => {
    const db: Record<string, string> = {
      北京: "晴天,27℃",
      上海: "多云,29℃",
    };
    return db[city] ?? "暂不支持该城市";
  },
});

toolRegistry.register({
  name: "calculate",
  description: "执行数学计算",
  parameters: { expression: "数学表达式字符串" },
  execute: async ({ expression }) => {
    try {
      return String(eval(expression));
    } catch {
      return "表达式错误";
    }
  },
});

const systemPrompt = `你是一个智能助手。你可以使用以下工具:
${toolRegistry.describe()}

请判断用户输入是否需要调用工具。如果需要,返回调用信息;如果不需要,直接回答。`;

const memory = new Memory(systemPrompt);
const agent = new Agent(memory, toolRegistry);

(async () => {
  console.log("=== 第 1 轮 ===");
  const r1 = await agent.run("北京今天天气怎么样?");
  console.log("🤖 Agent:", r1);

  console.log("\n=== 第 2 轮:多轮对话 ===");
  const r2 = await agent.run("那上海呢?");
  console.log("🤖 Agent:", r2);

  console.log("\n=== 第 3 轮:直接回答 ===");
  const r3 = await agent.run("你能做什么?");
  console.log("🤖 Agent:", r3);
})();

4.2 运行方式

bash 复制代码
npx tsx agent-engine.ts

如果你想用真实的大模型,只需要把 Planner.decide 里的 mock 逻辑替换为:

typescript 复制代码
async decide(messages: Message[]): Promise<AgentDecision> {
  const response = await openai.chat.completions.create({
    model: "gpt-4o",
    messages,
    tools: this.buildOpenAITools(), // 把 Tool[] 转成 OpenAI tools 格式
  });
  // 解析 response.choices[0].message.tool_calls
}

4.3 运行结果

text 复制代码
=== 第 1 轮 ===
🔧 [Loop 1] 调用工具:get_weather({"city":"北京"})
📥 [Loop 1] 工具返回:晴天,27℃
🤖 Agent: 查询到的天气结果是:晴天,27℃。

=== 第 2 轮:多轮对话 ===
🤖 Agent: 我是一个 Agent 助手。当前可用工具:
- get_weather(city: 城市名,如北京、上海): 查询指定城市的当前天气
- calculate(expression: 数学表达式字符串): 执行数学计算
你可以让我查天气或做数学计算。

=== 第 3 轮:直接回答 ===
🤖 Agent: 我是一个 Agent 助手。当前可用工具:
...

第二轮 "那上海呢?" 没有识别出指代,是因为我们的 mock LLM 太简单。真实 LLM 加上完整历史上下文后,能正确理解"那"指的是上海天气。


5. 源码核心底层剖析

5.1 输入解析器为什么不可缺少?

typescript 复制代码
class InputParser {
  parse(rawInput: string): Message {
    return { role: "user", content: rawInput.trim() };
  }
}

看起来只是 trim(),但千万别小看它。生产环境里,输入解析器承担的工作很多:

  • 清洗:去掉多余空格、特殊字符、注入攻击字符串。
  • 截断:防止用户塞一万字把上下文窗口撑爆。
  • 格式化 :把语音、图片、文件等不同输入统一成 Message 格式。
  • 意图分类:简单场景可以先做一级分类,再决定走哪个 Agent。

5.2 记忆模块的设计意图

typescript 复制代码
class Memory {
  private messages: Message[] = [];
  constructor(systemPrompt: string) { ... }
  add(message: Message) { ... }
  getMessages(): Message[] { ... }
}

记忆模块的核心职责:维护 LLM 能看到的历史上下文

这里有一个关键设计:系统提示词(system prompt)要放在第一位。因为 LLM 对开头内容更敏感,把"你是谁、你能做什么、规则是什么"放在第一条,能让模型更听话。

真实生产中,记忆模块还要解决:

  • 上下文太长怎么截断?
  • 哪些消息该保留,哪些该丢弃?
  • 长期记忆怎么存到向量数据库?

这些我们会在第二部分(上下文窗口)和第四部分(记忆系统)深入讲。

5.3 Planner 是怎么让模型做决策的?

真实的大模型 Planner 不是像 Demo 里那样写 if/else,而是通过提示词工程让模型自己输出结构化决策。

典型 prompt 结构如下:

text 复制代码
你是一个智能助手,可以调用以下工具:

## 工具列表
- get_weather(city: string): 查询天气
- calculate(expression: string): 数学计算

## 规则
1. 如果需要工具,请严格输出 JSON:
   {"type": "tool", "tool": "工具名", "args": {参数对象}}
2. 如果不需要工具,请输出 JSON:
   {"type": "answer", "content": "回复内容"}
3. 不要输出任何 JSON 之外的解释文字。

## 当前对话历史
{messages}

请根据以上信息做出决策。

模型返回的是 JSON,Agent 解析后就知道下一步干嘛。这就是结构化输出Function Call 的本质,第五部分会专门讲。

5.4 ToolExecutor 的错误处理为什么重要?

typescript 复制代码
async execute(decision) {
  const tool = this.registry.get(decision.tool);
  if (!tool) return `错误:未找到工具 ...`;
  try {
    return await tool.execute(decision.args);
  } catch (err) {
    return `工具执行出错:${err.message}`;
  }
}

注意这里我们没有抛异常,而是把异常信息作为 Observation 返回给模型

这是 Agent 和传统代码的一个巨大差异:

  • 传统代码:工具失败 → 抛异常 → 上层 catch → 返回错误给用户。
  • Agent:工具失败 → 把错误信息喂给 LLM → LLM 决定重试、换工具、还是道歉。

这样 Agent 才有可能"自愈"错误,而不是一崩到底。

5.5 观测回填(Observation Feedback)是核心循环的关键

typescript 复制代码
this.memory.add({
  role: "tool",
  content: `工具 "${decision.tool}" 返回结果是:"${observation}"。请根据这个结果回答用户。`,
});

这一步是把外部世界的信息重新拉回到 LLM 的脑子里。没有这一步,模型就不知道工具查到了什么,也就无法组织最终答案。

在企业级框架里,这条消息通常被称为 tool 角色消息或 observation 消息。

5.6 和 LangChain / Claude Code 的同源设计

这个 Demo 的架构和 LangChain 的 AgentExecutor 设计思想高度一致:

我们的模块 LangChain 对应概念 作用
InputParser Prompt Template / Input Variables 处理输入
Memory ConversationBufferMemory 维护历史
Planner Agent / LLMChain 让模型决策
ToolRegistry Tool Toolkit 工具集合
ToolExecutor ToolExecutor 执行工具
OutputGenerator OutputParser 解析最终输出

Claude Code 的底层也类似:它维护一个消息列表,每次把当前状态发给模型,模型决定调用 bash、文件读写还是编辑器工具,直到任务完成。


6. 常见问题 & 生产踩坑总结

Q1:为什么我的 Agent 总是调用错工具?

绝大多数是因为工具描述写得太模糊

反例:

text 复制代码
- search: 搜索

正例:

text 复制代码
- search(query: string): 当用户需要查找互联网实时信息、新闻、定义时使用。query 应该是 3-5 个关键词。

描述里要写清楚:什么时候用、参数怎么取、返回什么

Q2:工具执行失败,Agent 无限重试怎么办?

一定会遇到真实 API 超时、参数错误、权限不足。必须加控制:

  • 单次工具调用超时设置。
  • 单轮最大重试次数(比如 2 次)。
  • 记录失败原因,避免重复调用同一个错误参数。
  • 超过重试次数后,直接生成道歉回复。

Q3:大模型返回的 JSON 解析失败?

模型有时候会"嘴瓢",在 JSON 外面包一层废话,或者丢括号。解决方案:

  • 系统提示词要求 "只输出 JSON"。
  • 用正则从文本里提取 JSON。
  • 使用 LLM 的 response_format: { type: "json_object" } 或 Function Call。
  • 解析失败时返回错误给模型,让它重试。

Q4:Observation 太长,把上下文窗口塞满了?

比如查数据库返回了一万行数据。你需要:

  • 对 Observation 做截断、摘要、聚合。
  • 只把关键字段返回给模型。
  • 必要时用 RAG/向量检索,而不是直接把全量数据塞进去。

Q5:上下文一多,模型就"失忆"?

LLM 的上下文窗口是有限的。解决方向:

  • 只保留最近 N 轮对话。
  • 对久远对话做摘要。
  • 把关键信息抽出来放到长期记忆里。

Q6:前后端怎么分工?

建议按这样划分:

工作 前端 后端 Agent
收集用户输入
展示回复/流式渲染
调用 LLM
调用工具/API
管理上下文记忆
错误兜底/超时控制

前端只负责"界面"和"展示",Agent 的核心逻辑全放后端。


7. 本章小结

核心知识点

  1. Agent 不是单函数,而是由 输入解析器、记忆模块、推理规划器、工具执行器、输出生成器 五个模块组成的流水线。
  2. 执行流程是循环的:输入 → 记忆加载 → 思考决策 → 工具执行 → 观察回填 → (再思考)→ 输出
  3. 模块化设计的目的是为了可测试、可扩展、可定位 Bug
  4. 工具执行失败不要抛异常,而应把错误作为 Observation 喂回模型,让模型自愈。
  5. 提示词质量决定模型决策质量,工具描述要写得像 API 文档。

2.2 大模型上下文窗口与超长对话管理


1. 开篇导读

这部分解决什么问题?

你搭好了 Agent 的骨架,也知道了 Agent 要维护一个 messages 历史记录喂给大模型。但你有没有想过:

  • 对话越来越长, messages 里的内容越来越多,会不会把大模型"撑爆"?
  • 为什么有时候聊着聊着,Agent 突然忘记了自己的身份,或者忘记用户前面说过的话?
  • 模型说"只能处理 4096 tokens",这个 token 到底是什么?怎么算的?
  • 企业里的客服 Agent 一聊就是几十轮,怎么保证不"失忆"?

这一部分我们就要彻底讲清楚**上下文窗口(Context Window)**的原理,以及超长对话下该怎么管理记忆。

学完这部分你能掌握什么?

  • 理解 Token 是什么,以及上下文窗口为什么有上限。
  • 学会用滑动窗口、摘要记忆、Token 预算三种手段管理超长对话。
  • 写出一个可运行的记忆管理器,能根据 Token 数量自动丢弃或总结旧消息。

适合什么场景?

  • 多轮客服 Agent、销售助手、面试机器人。
  • 长文档分析 Agent(需要把大段文本分批喂给模型)。
  • 代码助手 Agent(长上下文项目文件分析)。
  • 任何需要控制成本、防止上下文溢出的生产系统。

2. 核心原理通俗讲解

2.1 上下文窗口是什么?

你可以把大模型想象成一个只能记住有限事情的人 。你和他聊天时,他不是记住你一辈子说的每句话,而是只记住最近一张纸上的内容。这张纸能写多少字,就是它的上下文窗口

不同模型的"纸"大小不一样:

模型 上下文窗口
GPT-4o mini 128K tokens
GPT-4o 128K tokens
Claude 3.5 Sonnet 200K tokens
Qwen2.5 128K tokens
Llama 3 128K tokens

注意:窗口大不代表成本低。消息越长,调用 API 越贵、越慢。

2.2 Token 到底是什么?

Token 是大模型处理文本的最小单位,不是你理解的"一个汉字"或"一个英文单词"。

简单理解:

  • 英文里,1 个 token 大约等于 0.75 个单词。
  • 中文里,1 个汉字通常占 1~2 个 token。
  • 数字、标点、空格都可能单独算 token。

举个例子:

text 复制代码
"今天北京天气怎么样?"
→ 大约 8~10 个 token

"The weather in Beijing is nice today."
→ 大约 7~8 个 token

真实项目中,你要用 tiktoken(OpenAI)或类似库来精确计算。我们后面代码里先用简化版模拟,生产再替换成真实 tokenizer。

2.3 为什么上下文窗口会"用完"?

每轮对话,你都要把整个 messages 数组发给大模型。假设对话进行了 20 轮:

json 复制代码
[
  { "role": "system", "content": "你是客服助手..." },
  { "role": "user", "content": "我想退货" },
  { "role": "assistant", "content": "请问订单号是多少?" },
  { "role": "user", "content": "12345" },
  // ... 后面还有 30 条
]

如果总 token 数超过了模型窗口,就会触发 上下文溢出(Context Overflow)。常见后果:

  1. API 直接报错,提示上下文太长。
  2. 模型自动截断,把最早的消息丢掉------而最早的消息里往往有最重要的系统提示。
  3. 模型"失忆",忘记自己的角色和用户的初始需求。
  4. 成本飙升,因为计费是按 token 数算的。

2.4 类比:上下文窗口就像鱼的记忆

网上有个老梗:鱼的记忆只有 7 秒。大模型也差不多,它只能看到当前窗口里的内容。

如果你喂了太多消息,模型就会"只记得最近几件事",前面的重要指令被挤出了窗口。

所以,做 Agent 记忆管理就是一句话:在有限的窗口里,保留最重要的信息

2.5 三种主流超长对话处理方案

方案 1:滑动窗口(Sliding Window)

只保留最近 N 条消息,超出部分直接丢弃。

优点:简单、可控、速度快。

缺点:容易丢掉早期关键信息,比如用户的初始需求。

方案 2:摘要记忆(Summary Memory)

把久远对话压缩成一段摘要,只保留最近完整消息 + 历史摘要。

优点:保留长期信息,对话连贯性好。

缺点:摘要可能丢失细节,且需要额外调用模型生成摘要(有成本)。

方案 3:Token 预算 + 智能截断(Token Budget)

设定一个最大 Token 数(比如 3000),超过时按策略丢弃消息:先丢用户无关的,保留 system prompt 和最近对话。

优点:精细控制,不把 system prompt 挤出去。

缺点:实现稍复杂,需要精确计算 token。

实际生产里,通常是三种方案组合使用


3. 架构流程图 / 原理图

3.1 上下文窗口工作原理

flowchart LR subgraph Window[上下文窗口 / 8K tokens] direction LR S[System Prompt<br/>1K] U1[User Q1] A1[Assistant A1] U2[User Q2] A2[Assistant A2] U3[User Q3] Dot[...] end Overflow[超出窗口的历史消息<br/>被截断/摘要] Window -->|满了之后新消息进来| Overflow

3.2 三种记忆管理方案对比

flowchart TD subgraph Sliding[滑动窗口] A1[m1] --> A2[m2] --> A3[m3] --> A4[m4] style A1 fill:#ffcccc end subgraph Summary[摘要记忆] B1[摘要...] --> B2[m3] --> B3[m4] style B1 fill:#ccffcc end subgraph Budget[Token 预算] C1[System] --> C2[m2] --> C3[m3] --> C4[m4] style C1 fill:#ccccff end

3.3 Token 预算控制流程

flowchart TD Start([收到新消息]) --> Count[计算当前总 Token] Count --> Check{是否超过预算?} Check -->|否| Send[直接发送给 LLM] Check -->|是| Strategy{选择策略} Strategy -->|滑动窗口| Drop[丢弃最旧消息] Strategy -->|摘要| Summarize[总结旧消息为摘要] Strategy -->|混合| Hybrid[保留关键消息<br/>摘要非关键消息] Drop --> Count Summarize --> Count Hybrid --> Count Send --> End([LLM 处理])

4. 手把手实操代码(可直接运行)

下面的代码实现了一个带 Token 预算管理 的记忆模块。它会自动维护消息列表,超过预算时优先保留 system prompt 和最近对话,丢弃中间的消息。

4.1 代码:memory-manager.ts

typescript 复制代码
// memory-manager.ts
// 超长对话管理:滑动窗口 + Token 预算 + 摘要记忆

type Message = {
  role: "system" | "user" | "assistant" | "tool";
  content: string;
};

// ================== 1. Token 计数器(简化版) ==================

class TokenCounter {
  /**
   * 真实项目里,你应该用 tiktoken:
   * import { encoding_for_model } from "tiktoken";
   * const enc = encoding_for_model("gpt-4o");
   * return enc.encode(text).length;
   *
   * 这里用字符数估算,只是为了 demo 可跑。
   */
  count(text: string): number {
    // 中文大约 1 字 ≈ 1 token,英文 1 词 ≈ 1.3 token
    const chinese = (text.match(/[\u4e00-\u9fa5]/g) ?? []).length;
    const others = text.length - chinese;
    return Math.ceil(chinese + others * 0.6);
  }

  countMessages(messages: Message[]): number {
    return messages.reduce((sum, m) => sum + this.count(m.content) + 4, 0);
    // +4 是 role 和格式化开销的粗略估算
  }
}

// ================== 2. 滑动窗口记忆 ==================

class SlidingWindowMemory {
  protected messages: Message[] = [];
  protected counter = new TokenCounter();

  constructor(
    systemPrompt: string,
    protected maxWindowSize: number // 保留最近多少条消息
  ) {
    this.messages.push({ role: "system", content: systemPrompt });
  }

  add(message: Message) {
    this.messages.push(message);
    this.trim();
  }

  getMessages(): Message[] {
    return this.messages;
  }

  protected trim() {
    // 永远保留 system prompt(第 0 条)
    const head = this.messages[0];
    const body = this.messages.slice(1);

    while (body.length > this.maxWindowSize) {
      body.shift();
    }

    this.messages = [head, ...body];
  }

  info(): string {
    const tokens = this.counter.countMessages(this.messages);
    return `SlidingWindowMemory: ${this.messages.length} 条消息,约 ${tokens} tokens`;
  }
}

// ================== 3. Token 预算记忆 ==================

class TokenBudgetMemory extends SlidingWindowMemory {
  constructor(
    systemPrompt: string,
    maxWindowSize: number,
    private maxTokens: number
  ) {
    super(systemPrompt, maxWindowSize);
  }

  protected override trim() {
    const head = this.messages[0];
    let body = this.messages.slice(1);

    // 先按窗口大小裁
    while (body.length > this.maxWindowSize) {
      body.shift();
    }

    // 再按 token 预算裁
    while (
      body.length > 0 &&
      this.counter.countMessages([head, ...body]) > this.maxTokens
    ) {
      body.shift();
    }

    this.messages = [head, ...body];
  }

  override info(): string {
    const tokens = this.counter.countMessages(this.messages);
    return `TokenBudgetMemory: ${this.messages.length} 条消息,约 ${tokens}/${this.maxTokens} tokens`;
  }
}

// ================== 4. 摘要记忆 ==================

class SummaryMemory {
  private messages: Message[] = [];
  private summary = ""; // 历史摘要
  private counter = new TokenCounter();

  constructor(
    systemPrompt: string,
    private maxRecentMessages: number,
    private maxTokens: number
  ) {
    this.messages.push({ role: "system", content: systemPrompt });
  }

  add(message: Message) {
    this.messages.push(message);
    this.compress();
  }

  getMessages(): Message[] {
    // 输出:system + 摘要 + 最近消息
    const result: Message[] = [this.messages[0]];
    if (this.summary) {
      result.push({
        role: "system",
        content: `历史对话摘要:${this.summary}`,
      });
    }
    result.push(...this.messages.slice(-this.maxRecentMessages));
    return result;
  }

  private compress() {
    // 保留最近完整消息数量,把更早的消息"摘要化"
    const system = this.messages[0];
    const body = this.messages.slice(1);

    if (body.length > this.maxRecentMessages) {
      const toSummarize = body.slice(0, body.length - this.maxRecentMessages);
      const summaryPoints = toSummarize
        .map((m) => `${m.role}: ${m.content.slice(0, 30)}...`)
        .join(";");
      this.summary = this.summary
        ? `${this.summary};${summaryPoints}`
        : summaryPoints;

      body.splice(0, body.length - this.maxRecentMessages);
      this.messages = [system, ...body];
    }

    // Token 超预算时,进一步压缩摘要
    while (
      this.messages.length > 1 &&
      this.counter.countMessages(this.getMessages()) > this.maxTokens
    ) {
      const system = this.messages[0];
      const rest = this.messages.slice(1);
      if (rest.length > 1) {
        const dropped = rest.shift();
        this.summary += `;${dropped?.role}: ${dropped?.content.slice(0, 20)}...`;
        this.messages = [system, ...rest];
      } else {
        break;
      }
    }
  }

  info(): string {
    return `SummaryMemory: ${this.messages.length} 条原始消息,摘要长度 ${this.summary.length} 字符`;
  }
}

// ================== 5. 演示 ==================

function demoSlidingWindow() {
  console.log("\n=== 演示 1:滑动窗口 ===");
  const mem = new SlidingWindowMemory("你是一个客服助手", 3);

  for (let i = 1; i <= 6; i++) {
    mem.add({ role: "user", content: `第 ${i} 轮用户问题,内容比较长用来占位` });
    mem.add({ role: "assistant", content: `第 ${i} 轮助手回复,内容也比较长` });
  }

  console.log(mem.info());
  console.log(
    "当前消息列表:",
    mem.getMessages().map((m) => `${m.role}: ${m.content.slice(0, 20)}...`)
  );
}

function demoTokenBudget() {
  console.log("\n=== 演示 2:Token 预算 ===");
  const mem = new TokenBudgetMemory("你是一个客服助手", 100, 150);

  for (let i = 1; i <= 6; i++) {
    mem.add({ role: "user", content: `第 ${i} 轮用户问题,内容很长很长很长` });
    mem.add({ role: "assistant", content: `第 ${i} 轮助手回复,内容也很长很长` });
  }

  console.log(mem.info());
}

function demoSummary() {
  console.log("\n=== 演示 3:摘要记忆 ===");
  const mem = new SummaryMemory("你是一个客服助手", 4, 300);

  for (let i = 1; i <= 8; i++) {
    mem.add({ role: "user", content: `第 ${i} 轮用户问题` });
    mem.add({ role: "assistant", content: `第 ${i} 轮助手回复` });
  }

  console.log(mem.info());
  console.log(
    "最终喂给模型的消息:",
    mem.getMessages().map((m) => `${m.role}: ${m.content.slice(0, 40)}...`)
  );
}

demoSlidingWindow();
demoTokenBudget();
demoSummary();

4.2 运行方式

bash 复制代码
npx tsx memory-manager.ts

4.3 生产环境升级建议

真实 Token 计算请用专业库:

bash 复制代码
npm install js-tiktoken
typescript 复制代码
import { encoding_for_model } from "js-tiktoken";

function countTokens(text: string): number {
  const enc = encoding_for_model("gpt-4o");
  return enc.encode(text).length;
}

5. 源码核心底层剖析

5.1 为什么 Token 计数不能只看字符数?

大模型不是按字符理解文本的。它先把文本切分成 token,再把 token 转成向量。同一个意思,token 数可能差异很大:

text 复制代码
"Hello"        → 1 token
"H e l l o"    → 5 tokens
"你好"         → 2 tokens
"人工智能技术" → 4~6 tokens

所以生产环境必须用 tokenizer,不能自己拍脑袋估算。

5.2 为什么 system prompt 必须绝对保留?

如果你不加保护,滑动窗口会按时间顺序丢消息。一旦 system prompt 被挤出窗口,模型就会:

  • 忘记自己是什么角色。
  • 输出格式变得不稳定。
  • 安全约束失效(比如开始说违禁内容)。

所以在所有截断策略里,system prompt 通常要单独锚定在第一条

5.3 滑动窗口和摘要记忆怎么选?

场景 推荐方案
短对话(<10 轮) 全量保留,不用截断
中等对话,近期信息更重要 滑动窗口
长对话,早期意图很关键 摘要记忆
企业客服、需要严格控制成本 Token 预算 + 摘要 + 滑动窗口组合

5.4 摘要记忆的真实实现更复杂

Demo 里的摘要只是简单拼接。真实项目里,你需要:

  • 调用 LLM 生成高质量摘要。
  • 区分"事实摘要"和"任务状态摘要"。
  • 把关键实体(用户名、订单号、偏好)单独抽出来保存,避免摘要时丢失。

高级玩法是用向量数据库存储长期记忆,第四部分会展开。

5.5 Token 预算控制为什么要先按窗口裁、再按 Token 裁?

这是为了效率。如果每条消息都精确算 token,会频繁调用 tokenizer,性能差。

优化策略:

  1. 先用消息条数快速粗筛(滑动窗口)。
  2. 接近预算时,再精确计算 token 并精细截断。

这和数据库查询先用索引粗排、再精准排序是一个道理。


6. 常见问题 & 生产踩坑总结

Q1:模型上下文窗口足够大,我是不是不用管了?

窗口大不代表成本低、速度快。128K 上下文调用一次可能几毛钱,如果每轮都塞满,一天下来成本很高。另外,上下文越长,模型注意力越分散,越容易"漏看"关键信息。

Q2:为什么加了摘要后,模型还是记不住订单号?

摘要会压缩细节。重要实体不要只依赖摘要,应该单独抽出来存在结构化记忆里。比如:

json 复制代码
{
  "user_id": "u123",
  "order_id": "ORD-2024-001",
  "preference": "习惯中文回复"
}

每次对话前把这些信息注入 system prompt。

Q3:工具调用结果太长怎么办?

大段 Observation 也是上下文的一部分。处理方案:

  • 只返回关键字段。
  • 对结果做结构化压缩。
  • 超长结果分批处理(Map-Reduce 思路)。

Q4:System prompt 被挤出窗口后,模型变傻了?

这是最常见的 Bug 之一。务必在所有截断策略里保证:

typescript 复制代码
const head = messages[0];
// head 永远是 system prompt,不能被删掉

Q5:怎么预估一个月的 Token 成本?

公式:

text 复制代码
单次调用 Token 数 × 调用次数 × Token 单价

可以在 Agent 里加日志,统计每次请求的 input/output token,再乘上价格。很多 Agent 框架都内置了这个模块。


7. 本章小结

核心知识点

  1. 上下文窗口是大模型一次能看到的最大 token 数量,超出会溢出或截断。
  2. Token 是模型处理文本的最小单位,中文和英文的 token 数不同,生产环境要用 tokenizer 精确计算。
  3. 三种记忆管理方案:滑动窗口(简单)、摘要记忆(保留长期信息)、Token 预算(精细控制成本)。
  4. System prompt 必须锚定保留,否则模型会忘记角色和规则。
  5. 长 Observation 也要压缩,避免挤爆上下文。

2.3 流式输出 SSE 与前端实时渲染


1. 开篇导读

这部分解决什么问题?

你有没有想过:为什么 ChatGPT 回答问题是一个字一个字"打字"出来的,而不是等全部想完了一次性给你?

这种效果背后用的就是 SSE(Server-Sent Events,服务器推送事件)。对 Agent 来说,流式输出不是"炫技",而是必需品:

  • 大模型生成速度不快,用户等好几秒才看到回复,体验很差。
  • 流式输出让用户"先看到回应",感知上快很多。
  • Agent 调用工具时,可以先告诉用户"我在查资料",提升信任感。
  • 前端可以边收边渲染,实现打字机效果、Markdown 实时高亮等交互。

这一部分,我们就从零实现一个基于 SSE 的 Agent 流式服务,并写一个简单的网页来实时渲染。

学完这部分你能掌握什么?

  • 理解 SSE 协议和它与 WebSocket、长轮询的区别。
  • 知道大模型流式输出(streaming)的底层数据格式。
  • 写出后端 SSE 接口 + 前端实时渲染的完整可运行 Demo。
  • 掌握在流式输出里混入"工具调用事件"的技巧。

适合什么场景?

  • AI 聊天机器人、客服系统的前端展示。
  • Agent 执行耗时任务时的进度提示。
  • 代码生成、文档生成等需要边生成边展示内容的场景。
  • 需要取消/中断生成的交互(比如用户点了"停止生成")。

2. 核心原理通俗讲解

2.1 为什么普通 API 不能流式输出?

我们平时调用 REST API 是这样的:

text 复制代码
前端:发一个 HTTP 请求
后端:等我全部算完,再一次性返回整个 JSON
前端:拿到 JSON,显示出来

如果后端要算 5 秒钟,用户就在这 5 秒内什么都看不到,体验很差。

流式输出希望变成这样:

text 复制代码
前端:发一个 HTTP 请求
后端:生成第一个 token → 立刻发送给前端
后端:生成第二个 token → 立刻发送给前端
后端:生成第三个 token → 立刻发送给前端
...
前端:收到一个就显示一个,像打字一样

这就需要一个"服务端能主动、持续、单向地推送数据"的机制,SSE 就是干这个的。

2.2 SSE 是什么?

SSE(Server-Sent Events) 是浏览器原生支持的一种 HTTP 推送技术。它的特点:

  • 基于普通 HTTP,不用像 WebSocket 那样握手升级协议。
  • 服务端可以源源不断地向客户端推送文本数据。
  • 浏览器端用 EventSource 接收,自动维护连接和重连。
  • 每个数据块格式是 data: ...\n\n

2.3 SSE vs WebSocket vs 长轮询

方案 方向 协议 复杂度 适用场景
SSE 服务端 → 客户端单向 HTTP 流式输出、实时通知
WebSocket 双向 WS/WSS 实时聊天、协同编辑
长轮询 客户端反复请求 HTTP 老式系统兼容

对于大模型流式输出,SSE 是最佳默认选择:服务端持续推数据,前端不需要发消息回去。

2.4 SSE 的数据长什么样?

SSE 的数据格式很简单:

text 复制代码
Content-Type: text/event-stream

id: 1
event: message
data: {"chunk": "你好"}

id: 2
event: message
data: {"chunk": ","}

id: 3
event: message
data: {"chunk": "我是"}

event: done
data: [DONE]

每个数据块以两个换行 \n\n 结尾。event 是事件名,data 是具体数据。前端收到后可以按 event 名分别处理。

2.5 大模型流式输出的本质

大模型生成回答时,其实也是一个 token 一个 token 地"吐"出来的。比如:

text 复制代码
用户:北京天气怎么样?
模型内部:
  token 1: 北京
  token 2: 今天
  token 3: 天气
  token 4: 晴
  token 5: 朗
  ...

大模型 API(OpenAI、Claude 等)都支持 stream: true 参数,服务端会把这些 token 逐一推过来。我们的工作就是:

  1. 接收模型的 token 流。
  2. 把每个 token 包装成 SSE 数据块。
  3. 通过 HTTP 推给前端。
  4. 前端收到后追加显示。

3. 架构流程图 / 原理图

3.1 Agent 流式输出整体架构

flowchart LR User[用户] --> Frontend[前端页面] Frontend -->|HTTP GET /chat?msg=...| Server[后端 SSE 服务] Server -->|stream: true| LLM[大模型 API] LLM -->|delta token| Server Server -->|SSE data: {...}| Frontend Frontend -->|实时渲染| UI[显示区域]

3.2 前端实时渲染流程

flowchart TD Start([用户输入]) --> Send[创建 EventSource] Send --> Listen[监听 onmessage] Listen --> Parse[解析 SSE data] Parse --> Type{事件类型} Type -->|text| Append[追加到当前回复] Type -->|tool_call| ShowTool[显示工具执行中] Type -->|done| Finish[结束渲染] Type -->|error| ShowError[显示错误] Append --> Listen ShowTool --> Listen Finish --> End([完成]) ShowError --> End

3.3 完整时序图

sequenceDiagram participant U as 用户 participant F as 前端 participant S as 后端 SSE 服务 participant L as 大模型 U->>F: 输入&#34;北京天气怎么样?&#34; F->>S: GET /chat?message=... S->>L: chat.completions.create({stream: true}) L-->>S: chunk 1: &#34;北京&#34; S-->>F: event: text, data: &#34;北京&#34; F->>F: 渲染&#34;北京&#34; L-->>S: chunk 2: &#34;今天&#34; S-->>F: event: text, data: &#34;今天&#34; F->>F: 渲染&#34;北京今天&#34; L-->>S: chunk 3: &#34;天气&#34; S-->>F: event: text, data: &#34;天气&#34; F->>F: 渲染&#34;北京今天天气&#34; S-->>F: event: done F->>F: 关闭 EventSource

4. 手把手实操代码(可直接运行)

下面给你一个完整的 Demo:

  • 后端:server.ts(Node.js + Express,SSE 接口)
  • 前端:index.html(原生 JS + EventSource)

4.1 后端代码:server.ts

typescript 复制代码
// server.ts
// 一个基于 SSE 的 Agent 流式输出服务

import express, { Request, Response } from "express";
import cors from "cors";

const app = express();
const PORT = 3001;

app.use(cors());
app.use(express.json());

// 模拟工具:查天气
async function getWeather(city: string): Promise<string> {
  const db: Record<string, string> = {
    北京: "晴天,27℃",
    上海: "多云,29℃",
  };
  return new Promise((resolve) => {
    setTimeout(() => resolve(db[city] ?? "未知城市"), 300);
  });
}

// 模拟大模型流式生成:把一段文本拆成多个 token 逐个返回
async function* mockLLMStream(text: string, delay = 80) {
  // 简单按每个字切分,真实项目按 tokenizer 切分
  for (const char of text) {
    await new Promise((r) => setTimeout(r, delay));
    yield char;
  }
}

// SSE 接口
app.get("/chat", async (req: Request, res: Response) => {
  const message = String(req.query.message ?? "");

  // 1. 设置 SSE 响应头
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");
  res.setHeader("X-Accel-Buffering", "no"); // 防止 Nginx 缓冲

  // 辅助函数:发送 SSE 事件
  const send = (event: string, data: string) => {
    res.write(`event: ${event}\n`);
    res.write(`data: ${data}\n\n`);
  };

  try {
    // 2. 判断是否需要调用工具(真实项目由 LLM 决策)
    const weatherMatch = message.match(/([\u4e00-\u9fa5]+?)(?:的|今天)?天气/);

    if (weatherMatch) {
      const city = weatherMatch[1];
      send("tool_call", JSON.stringify({ tool: "get_weather", args: { city } }));

      const weather = await getWeather(city);
      send("tool_result", JSON.stringify({ tool: "get_weather", result: weather }));

      const reply = `${city}今天${weather}。`;
      for await (const char of mockLLMStream(reply)) {
        send("text", char);
      }
    } else {
      const reply = `我是一个 Agent 助手,收到你的消息:"${message}"。我可以帮你查天气。`;
      for await (const char of mockLLMStream(reply)) {
        send("text", char);
      }
    }

    send("done", "[DONE]");
  } catch (err: any) {
    send("error", err.message);
  } finally {
    res.end();
  }
});

app.listen(PORT, () => {
  console.log(`SSE 服务已启动: http://localhost:${PORT}/chat?message=北京天气`);
});

4.2 前端代码:index.html

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Agent SSE 流式输出 Demo</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 680px; margin: 40px auto; padding: 0 16px; }
    #output { white-space: pre-wrap; border: 1px solid #ddd; padding: 16px; min-height: 120px; border-radius: 8px; background: #fafafa; }
    .tool-call { color: #888; font-size: 14px; margin-bottom: 8px; }
    .input-row { display: flex; gap: 8px; margin-top: 16px; }
    input { flex: 1; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; }
    button { padding: 8px 16px; border: none; background: #1677ff; color: white; border-radius: 6px; cursor: pointer; }
    button:disabled { background: #aaa; }
  </style>
</head>
<body>
  <h1>Agent 流式输出 Demo</h1>
  <div id="output"></div>
  <div class="input-row">
    <input id="msg" type="text" placeholder="输入:北京今天天气怎么样?" />
    <button id="send">发送</button>
  </div>

  <script>
    const output = document.getElementById("output");
    const input = document.getElementById("msg");
    const btn = document.getElementById("send");

    let currentText = "";
    let es = null;

    function appendText(char) {
      currentText += char;
      output.textContent = currentText;
    }

    function setStatus(html) {
      const status = document.createElement("div");
      status.className = "tool-call";
      status.innerHTML = html;
      output.appendChild(status);
    }

    function sendMessage() {
      const message = input.value.trim();
      if (!message) return;

      currentText = "";
      output.innerHTML = "";
      btn.disabled = true;
      input.disabled = true;

      const encoded = encodeURIComponent(message);
      es = new EventSource(`http://localhost:3001/chat?message=${encoded}`);

      es.onmessage = (e) => {
        // 未指定 event 的消息会进这里,但本例所有消息都有 event,所以一般用不上
      };

      es.addEventListener("text", (e) => {
        appendText(e.data);
      });

      es.addEventListener("tool_call", (e) => {
        const data = JSON.parse(e.data);
        setStatus(`🔧 正在调用工具:${data.tool}(${JSON.stringify(data.args)})`);
      });

      es.addEventListener("tool_result", (e) => {
        const data = JSON.parse(e.data);
        setStatus(`📥 工具返回:${data.result}`);
      });

      es.addEventListener("done", () => {
        cleanup();
      });

      es.addEventListener("error", (e) => {
        try {
          const data = JSON.parse(e.data);
          setStatus(`❌ 错误:${data}`);
        } catch {
          setStatus("❌ 连接异常");
        }
        cleanup();
      });
    }

    function cleanup() {
      if (es) {
        es.close();
        es = null;
      }
      btn.disabled = false;
      input.disabled = false;
    }

    btn.addEventListener("click", sendMessage);
    input.addEventListener("keydown", (e) => {
      if (e.key === "Enter") sendMessage();
    });
  </script>
</body>
</html>

4.3 怎么运行?

  1. 初始化项目:
bash 复制代码
mkdir sse-demo && cd sse-demo
npm init -y
npm install express cors @types/express @types/cors typescript
  1. server.tsindex.html 放到项目目录。
  2. 编译运行后端:
bash 复制代码
npx tsx server.ts
  1. 用浏览器打开 index.html(可以用 VS Code Live Server,或直接用 file:// 打开)。
  2. 输入"北京天气",看流式打字效果。

4.4 真实 LLM 的 SSE 接入

真实调用 OpenAI 时,代码类似这样:

typescript 复制代码
const stream = await openai.chat.completions.create({
  model: "gpt-4o",
  messages: [...],
  stream: true,
});

for await (const chunk of stream) {
  const delta = chunk.choices[0]?.delta?.content ?? "";
  send("text", delta);
}

send("done", "[DONE]");

差异只是把 mockLLMStream 换成真实的 SDK stream 调用,SSE 发送逻辑完全一样。


5. 源码核心底层剖析

5.1 SSE 响应头为什么这么设置?

typescript 复制代码
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
  • text/event-stream:告诉浏览器这是 SSE 流。
  • no-cache / keep-alive:防止中间件关闭连接或缓存响应。
  • X-Accel-Buffering: no:如果用了 Nginx 反向代理,这个头能禁用 Nginx 的响应缓冲,否则 SSE 会变"批处理",前端收不到实时数据。

5.2 SSE 数据格式解析

typescript 复制代码
res.write(`event: ${event}\n`);
res.write(`data: ${data}\n\n`);

关键点:

  • 每条消息以 \n\n 结尾,这是 SSE 协议规定的分隔符。
  • event 是事件名,前端用 addEventListener("事件名", handler) 监听。
  • data 是 payload,可以是纯文本或 JSON 字符串。
  • 如果 data 包含多行,每行都要写 data: ,浏览器会自动拼接。

5.3 为什么用 EventSource 而不是 fetch

EventSource 是浏览器原生 API,优点:

  • 自动解析 SSE 格式。
  • 自动重连(连接断开后会尝试重新连接)。
  • 可以按 event 名分发处理。

EventSource 只支持 GET 请求和纯文本。如果你需要:

  • 发送 POST/复杂 header
  • 自定义请求头(比如 Authorization)
  • 主动取消连接

就要改用 fetch + ReadableStream 自己解析 SSE。

5.4 fetch + ReadableStream 自己解析 SSE(生产推荐)

typescript 复制代码
const response = await fetch("/chat", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ message }),
});

const reader = response.body?.getReader();
const decoder = new TextDecoder();
let buffer = "";

while (reader) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });

  // 按 \n\n 拆分 SSE 数据块
  const chunks = buffer.split("\n\n");
  buffer = chunks.pop() ?? "";

  for (const chunk of chunks) {
    const event = chunk.match(/event: (.+)/)?.[1];
    const data = chunk.match(/data: (.+)/)?.[1];
    if (event && data) {
      handleEvent(event, data);
    }
  }
}

5.5 如何在流式输出里支持"停止生成"?

这是生产里必备功能。做法:

typescript 复制代码
const controller = new AbortController();
const stream = await openai.chat.completions.create(
  { ... },
  { signal: controller.signal }
);

// 用户点击"停止"
controller.abort();

前端配合 fetch 的 AbortController,后端配合取消流读取。

5.6 工具调用事件怎么设计?

在 SSE 里,工具调用可以通过自定义 event 类型传递:

text 复制代码
event: tool_call
data: {"tool": "get_weather", "args": {"city": "北京"}}

event: tool_result
data: {"tool": "get_weather", "result": "晴天,27℃"}

这样前端可以:

  • 显示"正在查询北京天气..."
  • 等工具返回后再继续流式渲染最终答案

6. 常见问题 & 生产踩坑总结

Q1:前端收不到实时数据,要等全部生成完才一次显示?

90% 是因为中间件缓冲。检查:

  • 后端是否设置了 X-Accel-Buffering: no
  • 是否用了 res.flush()(某些框架需要手动 flush)。
  • CDN/网关是否有响应缓存。

Express 通常会自动 flush,但 Go/FastAPI 等要注意手动 flush。

Q2:EventSource 不支持 POST 和自定义 Header?

对,这是浏览器限制。解决方法:

  • 用 URL query 传简单参数。
  • 复杂场景改用 fetch + ReadableStream

Q3:SSE 连接过一段时间自动断开?

可能原因:

  • 代理/负载均衡有 idle timeout(比如 60 秒没数据就断开)。
  • 大模型生成太慢,中间长时间没有 token。

解决方案:

  • 发送心跳(heartbeat),比如每 15 秒发一个 event: ping
  • 前端捕获 error 后自动重连。

Q4:用户快速连续发送消息,SSE 乱了?

每次发送都要新建一个独立的 EventSource,旧的要关闭。前端维护一个 currentEventSource,发新消息前先 close()

Q5:后端 SSE 压测性能如何?

SSE 是长连接,每个用户占用一个连接。高并发时要注意:

  • 限制单用户并发连接数。
  • 设置合理的超时和心跳。
  • 用支持长连接的服务器/网关(Nginx/Node.js 都可以)。

Q6:Markdown 渲染闪烁怎么办?

流式输出拼接 Markdown 时,如果每加一个 token 就重新渲染,公式/代码块会频繁闪烁。解决方案:

  • 对流式文本做防抖渲染(比如 100ms 渲染一次)。
  • 先显示纯文本流,生成结束后再统一渲染 Markdown。
  • 使用支持流式渲染的 Markdown 库(如 react-markdown + 增量更新优化)。

7. 本章小结

核心知识点

  1. 流式输出不是炫技,而是提升用户体验和感知速度的关键。
  2. SSE 是最适合 LLM 流式输出的协议:单向、基于 HTTP、浏览器原生支持。
  3. 后端核心工作是:设置 SSE 响应头 → 接收模型 token → 包装成 event/data 推给前端。
  4. 前端核心工作是:用 EventSourcefetch + ReadableStream 接收 → 按事件类型处理 → 实时追加渲染。
  5. 生产要注意:中间件缓冲、心跳保活、停止生成、Markdown 防抖渲染、长连接并发。

2.4 Agent 记忆系统:短期记忆、长期记忆、会话记忆实现


1. 开篇导读

这部分解决什么问题?

但现在的 Agent 仍然有一个致命缺陷:

  • 你前一句说"我叫张三",下一句它可能就忘了。
  • 你上周让它"记住我喜欢深色模式",今天重新打开,它没有印象。
  • 它查过的资料、解决过的问题,聊完就丢了。

这些问题的本质都是:Agent 没有记忆系统

人是靠记忆才能变得"聪明"的。大模型本身不是存储器,它不会自动记住你说过什么。想让 Agent 长期可用,必须给它设计一套记忆系统。

这一部分我们就来实现三种记忆:短期记忆、会话记忆、长期记忆

学完这部分你能掌握什么?

  • 理解三种记忆的职责边界。
  • 写出完整的记忆管理模块代码。
  • 实现一个简单的长期记忆检索(向量相似度搜索)。
  • 把记忆系统接入前面写的 Agent 引擎。

适合什么场景?

  • 智能客服:记住用户订单号、手机号、历史诉求。
  • 个人助手:记住用户偏好、口头禅、常用命令。
  • 代码助手:记住项目结构、习惯写法。
  • 企业内部 Agent:记住权限、流程、历史工单。

2. 核心原理通俗讲解

2.1 人的记忆 vs Agent 的记忆

人的记忆大致分三类:

人的记忆 Agent 对应 作用
短期记忆 / 工作记忆 短期记忆 记住当前几秒到几分钟的事
episodic / 情景记忆 会话记忆 记住本次聊天里的关键事实
长期知识 / 经验 长期记忆 跨会话、跨时间记住重要信息

套到 Agent 上:

  • 短期记忆 :当前对话上下文(messages 数组)。聊下一句话就用,关了窗口就没了。
  • 会话记忆:本次对话中提取出的关键变量,比如用户名字、订单号、当前任务状态。
  • 长期记忆:存在数据库/向量库里的信息,下次打开还能回忆起来。

2.2 三种记忆的关系

text 复制代码
┌─────────────────────────────────────────────────────┐
│                    用户提问                         │
└──────────────────┬──────────────────────────────────┘
                   ▼
┌─────────────────────────────────────────────────────┐
│  1. 长期记忆检索  →  找到历史上相关的事实            │
│     (向量数据库 / RAG)                             │
└──────────────────┬──────────────────────────────────┘
                   ▼
┌─────────────────────────────────────────────────────┐
│  2. 会话记忆读取  →  拿到本次对话的状态变量          │
│     (用户姓名、订单号、当前步骤)                   │
└──────────────────┬──────────────────────────────────┘
                   ▼
┌─────────────────────────────────────────────────────┐
│  3. 短期记忆加载  →  最近 N 轮对话                    │
│     (上下文窗口)                                   │
└──────────────────┬──────────────────────────────────┘
                   ▼
┌─────────────────────────────────────────────────────┐
│  4. 一起喂给大模型,生成回复                         │
└─────────────────────────────────────────────────────┘

2.3 短期记忆:只活在当下

短期记忆就是我们前面一直讲的 messages 数组。它只服务当前这次请求,优点是直接、实时;缺点是窗口有限、不能跨请求。

短期记忆的核心问题我们在第二部分已经讲过:太长要截断,system prompt 要保护。

2.4 会话记忆:本次聊天的"小抄"

会话记忆解决什么问题?

假设用户说:

text 复制代码
用户:我要退货,订单号是 12345。
Agent:好的,请问退货原因?
用户:质量问题。
Agent:好的,已登记。请问您的手机号是多少?
用户:13800138000。
Agent:好的,已提交,预计 3 个工作日处理。

这里的关键事实有:订单号 12345、退货原因 质量问题、手机号 13800138000、处理结果 已提交。

这些信息如果每次都靠大模型从长长上下文中"回忆",容易遗漏。更好的做法是:每轮对话后,把关键事实结构化地存起来

json 复制代码
{
  "user_name": "张三",
  "order_id": "12345",
  "phone": "13800138000",
  "intent": "退货",
  "status": "已提交"
}

这就是会话记忆。它不是纯文本,而是键值对

2.5 长期记忆:跨时空的大脑

长期记忆解决跨会话、跨时间的问题。比如你和一个 AI 助手聊了三个月,你希望它记得:

  • 你的名字。
  • 你喜欢的技术栈。
  • 你上个月问过的一个棘手 Bug。

这些信息不能都放在短期记忆里(窗口不够),也不能都放在会话记忆里(换个会话就没了)。

长期记忆的典型实现是:向量数据库 + Embedding 相似度检索

它的工作方式:

  1. 把每条记忆(一句话、一段事实)转换成一个高维向量(Embedding)。
  2. 存在向量数据库里。
  3. 用户提问时,把问题也转成向量,去库里找最相似的向量。
  4. 把找到的记忆塞进上下文,帮助模型回答。

这就是 RAG(检索增强生成)的核心思想。


3. 架构流程图 / 原理图

3.1 记忆系统整体架构

flowchart LR User[用户输入] --> Agent[Agent 引擎] Agent --> ShortTerm[短期记忆<br/>上下文窗口] Agent --> Session[会话记忆<br/>键值对状态] Agent --> LongTerm[长期记忆<br/>向量数据库] LongTerm --> Emb[Embedding 模型] Emb --> VectorDB[(向量数据库)] VectorDB --> Retrieve[相似度检索] Retrieve --> Agent Agent --> LLM[大模型] LLM --> Response[生成回复] Response --> ShortTerm Response --> Session Response --> LongTerm

3.2 长期记忆向量检索流程

flowchart TD Start([用户提问]) --> EmbedQ[把问题转成 Embedding 向量] EmbedQ --> Search[在向量库中搜索相似向量] Search --> Rank[按相似度排序取 Top K] Rank --> Inject[把检索结果注入 Prompt] Inject --> LLM[大模型生成回复] LLM --> End([输出])

3.3 三种记忆的读写时序

sequenceDiagram participant U as 用户 participant A as Agent participant S as 短期记忆 participant Se as 会话记忆 participant L as 长期记忆 participant LLM as 大模型 U->>A: 提问 A->>S: 读取最近对话 A->>Se: 读取会话状态 A->>L: 检索相关记忆 A->>LLM: 组合完整 Prompt LLM-->>A: 生成回复 A->>S: 写入本轮对话 A->>Se: 提取并更新会话变量 A->>L: 判断是否有值得长期保存的事实 A-->>U: 返回回复

4. 手把手实操代码(可直接运行)

下面的 Demo 实现三种记忆:

  • ShortTermMemory:维护最近对话上下文。
  • SessionMemory:维护键值对状态。
  • LongTermMemory:用向量相似度做长期记忆检索(用 mock embedding)。

4.1 代码:memory-system.ts

typescript 复制代码
// memory-system.ts
// Agent 记忆系统:短期记忆 + 会话记忆 + 长期记忆

// ================== 类型定义 ==================

type Message = {
  role: "system" | "user" | "assistant" | "tool";
  content: string;
};

type MemoryRecord = {
  id: string;
  content: string;
  vector: number[];
  metadata?: Record<string, any>;
};

// ================== 1. 短期记忆 ==================

class ShortTermMemory {
  private messages: Message[] = [];

  constructor(systemPrompt: string, private maxMessages = 10) {
    this.messages.push({ role: "system", content: systemPrompt });
  }

  add(message: Message) {
    this.messages.push(message);
    // 保留 system + 最近 maxMessages 条
    const head = this.messages[0];
    const body = this.messages.slice(1);
    if (body.length > this.maxMessages) {
      body.splice(0, body.length - this.maxMessages);
    }
    this.messages = [head, ...body];
  }

  getMessages(): Message[] {
    return this.messages;
  }
}

// ================== 2. 会话记忆 ==================

class SessionMemory {
  private state: Record<string, any> = {};

  set(key: string, value: any) {
    this.state[key] = value;
  }

  get(key: string): any {
    return this.state[key];
  }

  getAll(): Record<string, any> {
    return { ...this.state };
  }

  /**
   * 把会话状态转成一段文本,塞进 system prompt
   */
  toPromptSection(): string {
    if (Object.keys(this.state).length === 0) return "";
    const lines = Object.entries(this.state)
      .map(([k, v]) => `- ${k}: ${v}`)
      .join("\n");
    return `\n\n[当前会话已确认的信息]\n${lines}`;
  }
}

// ================== 3. 长期记忆(向量检索版) ==================

class LongTermMemory {
  private records: MemoryRecord[] = [];

  /**
   * 真实项目里,这里应该调用 OpenAI / 通义等 Embedding API。
   * Demo 用一个 mock 函数:把文本按字符编码转成简单向量。
   */
  private async mockEmbed(text: string): Promise<number[]> {
    const vec = [];
    for (let i = 0; i < 16; i++) {
      let sum = 0;
      for (let j = 0; j < text.length; j++) {
        sum += text.charCodeAt(j) * (j + 1) * (i + 1);
      }
      vec.push(Math.sin(sum) * 100);
    }
    return vec;
  }

  async add(content: string, metadata?: Record<string, any>) {
    const vector = await this.mockEmbed(content);
    this.records.push({
      id: Math.random().toString(36).slice(2),
      content,
      vector,
      metadata,
    });
  }

  async search(query: string, topK = 2): Promise<MemoryRecord[]> {
    const queryVec = await this.mockEmbed(query);

    const scored = this.records.map((record) => ({
      record,
      score: cosineSimilarity(queryVec, record.vector),
    }));

    scored.sort((a, b) => b.score - a.score);
    return scored.slice(0, topK).map((s) => s.record);
  }
}

function cosineSimilarity(a: number[], b: number[]): number {
  let dot = 0;
  let normA = 0;
  let normB = 0;
  for (let i = 0; i < a.length; i++) {
    dot += a[i] * b[i];
    normA += a[i] ** 2;
    normB += b[i] ** 2;
  }
  return dot / (Math.sqrt(normA) * Math.sqrt(normB) + 1e-8);
}

// ================== 4. 把它们组合成一个 Agent ==================

class AgentWithMemory {
  private shortTerm: ShortTermMemory;
  private session = new SessionMemory();
  private longTerm: LongTermMemory;

  constructor(systemPrompt: string, longTerm: LongTermMemory) {
    this.shortTerm = new ShortTermMemory(systemPrompt, 6);
    this.longTerm = longTerm;
  }

  async chat(userInput: string): Promise<string> {
    // 1. 检索长期记忆
    const relevant = await this.longTerm.search(userInput, 2);
    const memoryContext = relevant.length
      ? `\n\n[相关历史记忆]\n${relevant.map((r) => `- ${r.content}`).join("\n")}`
      : "";

    // 2. 读取会话记忆
    const sessionContext = this.session.toPromptSection();

    // 3. 组装 system prompt
    const baseSystem = this.shortTerm.getMessages()[0];
    const dynamicSystem = {
      ...baseSystem,
      content: baseSystem.content + sessionContext + memoryContext,
    };

    // 4. 加入用户消息
    this.shortTerm.add({ role: "user", content: userInput });

    // 5. 模拟 LLM 回复(真实项目调用 LLM API)
    const reply = this.mockReply(userInput, sessionContext, memoryContext);

    // 6. 写入短期记忆
    this.shortTerm.add({ role: "assistant", content: reply });

    // 7. 提取关键事实更新会话记忆(真实项目可用 LLM 做信息抽取)
    this.extractSessionFacts(userInput, reply);

    // 8. 判断是否写入长期记忆
    this.maybeStoreLongTerm(userInput, reply);

    return reply;
  }

  private mockReply(
    input: string,
    sessionContext: string,
    memoryContext: string
  ): string {
    // 简单模拟:如果会话记忆里已经有名字,就称呼用户
    const name = this.session.get("user_name");

    if (input.includes("我叫")) {
      return `好的,我记住你${name ? "的名字是" + name : "了"}。`;
    }

    if (input.includes("我喜欢")) {
      const pref = this.session.get("preference");
      return `收到,${name ?? ""}你喜欢 ${pref},我会记着的。`;
    }

    if (memoryContext && input.includes("上次")) {
      return `根据我的记忆:${memoryContext.replace(/\[相关历史记忆\]\n?/, "").split("\n")[0]}。`;
    }

    return `你好${name ? "," + name : ""}。我记住了当前会话信息。`;
  }

  private extractSessionFacts(input: string, reply: string) {
    // 真实项目:调用 LLM 抽取 JSON
    const nameMatch = input.match(/我叫(\S+)/);
    if (nameMatch) this.session.set("user_name", nameMatch[1]);

    const prefMatch = input.match(/我喜欢(.+)/);
    if (prefMatch) this.session.set("preference", prefMatch[1].trim());
  }

  private maybeStoreLongTerm(input: string, reply: string) {
    // 简单规则:包含"记住"的句子写入长期记忆
    if (input.includes("记住") || input.includes("我喜欢")) {
      this.longTerm.add(input);
    }
  }

  getSession() {
    return this.session.getAll();
  }
}

// ================== 5. 运行演示 ==================

(async () => {
  const longTermMemory = new LongTermMemory();

  // 预置一些长期记忆
  await longTermMemory.add("用户张志远常用 Vue3 + TypeScript 开发前端项目");
  await longTermMemory.add("张志远上个月遇到过一个 Webpack 热更新失效的问题");

  const agent = new AgentWithMemory(
    "你是一个聪明的个人助手。",
    longTermMemory
  );

  console.log("=== 第 1 轮 ===");
  console.log(await agent.chat("我叫张志远"));
  console.log("当前会话记忆:", agent.getSession());

  console.log("\n=== 第 2 轮 ===");
  console.log(await agent.chat("我喜欢用 Vue3"));
  console.log("当前会话记忆:", agent.getSession());

  console.log("\n=== 第 3 轮:触发长期记忆检索 ===");
  console.log(await agent.chat("我上次遇到什么 Bug 来着?"));

  console.log("\n=== 第 4 轮:跨会话模拟(新 Agent 实例但共用长期记忆)===");
  const newAgent = new AgentWithMemory(
    "你是一个聪明的个人助手。",
    longTermMemory
  );
  console.log(await newAgent.chat("我是谁?"));
})();

4.2 运行方式

bash 复制代码
npx tsx memory-system.ts

4.3 生产环境:把 mock embedding 换成真实 Embedding

typescript 复制代码
import OpenAI from "openai";

async function embed(text: string): Promise<number[]> {
  const res = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: text,
  });
  return res.data[0].embedding;
}

向量数据库也可以用 PostgreSQL + pgvector、Pinecone、Milvus、Qdrant 等。


5. 源码核心底层剖析

5.1 为什么记忆必须分三层?

只用一个 messages 数组,所有问题都会出现:

  • 窗口不够 → 信息丢失。
  • 跨会话没法保留 → 每次重开都失忆。
  • 关键事实藏在长文本里 → 模型注意不到。

分三层后:

  • 短期记忆负责"当前流畅对话"。
  • 会话记忆负责"本次任务的关键状态"。
  • 长期记忆负责"跨时间、跨任务的知识"。

每层用最适合的数据结构存储,效率最高。

5.2 会话记忆的信息抽取该怎么做?

Demo 里用正则简单抽取。真实项目里,你应在每次对话后调用一次小模型:

text 复制代码
系统提示:
请从以下对话中提取用户的核心信息,输出 JSON,只包含发生变化的事实。

对话:
用户:我叫张三
助手:好的。

输出:
{ "user_name": "张三" }

这样做的好处是:不需要写大量正则,模型自己判断什么值得记。

5.3 长期记忆的检索为什么用向量?

因为用户同一句话可以有很多种说法:

  • "我之前遇到什么 Bug?"
  • "我上次碰到的那个问题"
  • "Webpack 热更新失效"

关键词匹配很难覆盖所有表达。向量相似度能把这些问题都映射到相近的向量空间,从而召回相关记忆。

5.4 什么时候该写入长期记忆?

不是所有对话都值得长期保存,否则向量库会充满垃圾。常见策略:

  • 用户明确说"记住":"请记住我的工号是 123"。
  • 重要事实:姓名、偏好、关键决策、任务结果。
  • 周期性总结:每 10 轮对话自动总结一次。
  • 用户确认:"我把这个保存下来,方便下次使用,可以吗?"

5.5 和 LangChain / Claude Code 的同源设计

LangChain 提供了:

  • ConversationBufferMemory(短期记忆)
  • ConversationSummaryMemory(摘要记忆)
  • VectorStoreRetrieverMemory(长期向量记忆)

Claude Code 也维护了一个跨会话的上下文和记忆,包括项目结构、用户偏好、近期修改等。

我们的三层设计正是这些框架的简化底层思想。


6. 常见问题 & 生产踩坑总结

Q1:长期记忆检索出来不相关的内容?

这是 RAG 最常见的问题。优化方向:

  • 使用更好的 Embedding 模型(text-embedding-3-large 比 small 强很多)。
  • 把长文本切分成小块(chunking)。
  • 检索后加一层重排序(rerank)。
  • 给记忆加 metadata 过滤,比如 user_id 必须匹配。

Q2:会话记忆越积越多,反而干扰模型?

不要把所有变量都塞进 prompt。只放和当前问题相关的变量。可以:

  • 给会话记忆加 TTL(超过 30 分钟没提到的就淡化)。
  • 每次调用时让模型判断哪些变量相关。

Q3:长期记忆写入太频繁,成本高?

不是所有对话都立刻向量化。可以:

  • 先写入普通数据库。
  • 后台异步批量做 Embedding。
  • 每天晚上统一总结并建索引。

Q4:用户隐私和数据安全?

长期记忆里可能包含手机号、地址、公司内部信息。必须:

  • 按 user_id 隔离向量空间。
  • 敏感信息脱敏后再存储。
  • 支持用户删除自己的记忆。
  • 遵守数据保留政策,自动过期清理。

Q5:短期记忆截断后,模型忘了前面的任务?

如果当前任务需要前面很多轮的信息,单纯截断会出问题。解决方案:

  • 把当前任务状态放在会话记忆里。
  • 用长期记忆存储任务进展摘要。
  • 必要时让模型主动"总结当前进度"。

7. 本章小结

核心知识点

  1. Agent 的记忆分三层:短期记忆(当前上下文)会话记忆(本次任务状态)长期记忆(跨会话知识)
  2. 短期记忆管理窗口,会话记忆管理关键变量,长期记忆用向量检索。
  3. 长期记忆的底层技术是 Embedding + 向量相似度检索(RAG)
  4. 不是所有信息都值得长期保存,要有选择地写记忆。
  5. 生产环境要注意隐私隔离、检索相关性、成本控制和数据清理。

2.5 Agent 工具调用(Function Call)底层剖析


1. 开篇导读

这部分解决什么问题?

大模型只是一个文本生成器,它怎么知道该调用哪个工具、参数是什么?

很多初学者以为要训练模型才能让它调用工具,其实不是。OpenAI、Claude、通义等模型都支持 Function Call / Tool Use 机制:你告诉模型"有哪些工具、每个工具需要什么参数",模型就能在需要时输出一段结构化的指令,Agent 解析后执行即可。

这一部分我们要深入这个机制的最底层,手写一个工具调用解析器。

学完这部分你能掌握什么?

  • 理解 Function Call 的完整数据格式。
  • 写出工具注册、参数校验、函数调用的完整代码。
  • 掌握 OpenAI tool_calls 和 Claude tool_use 的差异。
  • 处理"模型输出非标准 JSON"、"参数缺失"、"选错工具"等生产问题。

适合什么场景?

  • 任何需要 Agent 调用外部 API 的场景。
  • 自定义工具平台、MCP 协议对接。
  • 表单 Agent、代码 Agent、数据分析 Agent。
  • 做多 Agent 协作时的工具编排。

2. 核心原理通俗讲解

2.1 大模型是怎么决定调用工具的?

你可以把 Function Call 理解成"给模型发一份产品说明书"。

模型拿到说明书后,会做两步判断:

  1. 需不需要用工具?
  2. 如果用,用哪一个、参数怎么填?

比如你给模型的说明书是:

text 复制代码
你可以使用以下工具:
- get_weather(city: string): 查询天气
- calculate(expression: string): 计算数学表达式

用户问"北京天气",模型看到说明书后就知道:

json 复制代码
{
  "tool": "get_weather",
  "args": { "city": "北京" }
}

用户问"你好",模型判断不需要工具,直接回答即可。

2.2 工具说明书长什么样?

现代 LLM API 都用 JSON Schema 描述工具。一个天气工具的 Schema 是:

json 复制代码
{
  "type": "function",
  "function": {
    "name": "get_weather",
    "description": "查询指定城市的当前天气",
    "parameters": {
      "type": "object",
      "properties": {
        "city": {
          "type": "string",
          "description": "城市名,如北京、上海"
        }
      },
      "required": ["city"]
    }
  }
}

关键点:

  • name:工具的唯一标识。
  • description:决定模型会不会选这个工具,要写得清楚。
  • parameters:参数的 JSON Schema,包括类型、描述、必填项。

2.3 模型返回的工具调用指令长什么样?

OpenAI 格式:

json 复制代码
{
  "role": "assistant",
  "content": null,
  "tool_calls": [
    {
      "id": "call_abc123",
      "type": "function",
      "function": {
        "name": "get_weather",
        "arguments": "{\"city\":\"北京\"}"
      }
    }
  ]
}

Claude 格式(Anthropic):

json 复制代码
{
  "role": "assistant",
  "content": [
    {
      "type": "tool_use",
      "id": "toolu_01xxx",
      "name": "get_weather",
      "input": { "city": "北京" }
    }
  ]
}

本质都一样:模型输出结构化数据,告诉 Agent 调哪个函数、传什么参数

2.4 Function Call 三步曲

整个工具调用流程可以概括为:

text 复制代码
1. 拼装工具说明书 → 发给模型
2. 模型返回 tool_call → Agent 解析
3. Agent 执行函数 → 把结果再喂给模型

其中第 3 步可能循环多次,直到模型决定直接回答。


3. 架构流程图 / 原理图

3.1 工具调用整体流程

flowchart TD A[用户输入] --> B[Agent 组装 Prompt] B --> C[工具 Schema 注入] C --> D[调用 LLM] D --> E{返回类型} E -->|普通文本| F[直接输出] E -->|tool_call| G[解析工具名 + 参数] G --> H[参数校验] H -->|校验通过| I[执行工具] H -->|校验失败| J[返回错误给 LLM] J --> D I --> K[生成 Observation] K --> L[把 Observation 回填到上下文] L --> D F --> M[返回给用户]

3.2 Agent 与工具的关系

flowchart LR subgraph LLM[大模型] SYS[系统提示] TOOLS[工具说明书] USER[用户输入] end LLM -->|决策| Decision{工具调用?} Decision -->|是| Call[tool_call JSON] Decision -->|否| Answer[普通文本] Call --> Executor[ToolExecutor] Executor --> ToolA[天气 API] Executor --> ToolB[计算器] Executor --> ToolC[数据库] ToolA --> Result[Observation] ToolB --> Result ToolC --> Result Result --> LLM

3.3 一次完整工具调用的时序

sequenceDiagram participant U as 用户 participant A as Agent participant LLM as 大模型 participant E as ToolExecutor participant T as 外部工具 U->>A: &#34;北京天气怎么样?&#34; A->>LLM: messages + tools schema LLM-->>A: tool_calls[0]<br/>name=get_weather<br/>args={&#34;city&#34;:&#34;北京&#34;} A->>A: 解析 + 校验参数 A->>E: execute(&#34;get_weather&#34;, args) E->>T: 调用天气 API T-->>E: &#34;晴天,27℃&#34; E-->>A: Observation A->>LLM: messages + tool result LLM-->>A: &#34;北京今天晴天,27℃。&#34; A-->>U: 返回答案

4. 手把手实操代码(可直接运行)

下面是一个纯 TypeScript 实现的工具调用引擎,包含:

  • 工具注册(JSON Schema 描述)。
  • 参数校验。
  • 工具调用解析器。
  • 执行循环。

不依赖任何第三方库,复制即可跑。

4.1 代码:function-call-engine.ts

typescript 复制代码
// function-call-engine.ts
// 手写一个 Function Call 工具调用引擎

// ================== 1. 类型定义 ==================

type JSONSchema = {
  type: string;
  description?: string;
  properties?: Record<string, JSONSchema>;
  required?: string[];
  items?: JSONSchema;
};

type ToolDefinition = {
  name: string;
  description: string;
  parameters: JSONSchema;
  execute: (args: any) => Promise<string>;
};

type ToolCall = {
  id: string;
  name: string;
  arguments: string; // JSON 字符串
};

type Message = {
  role: "system" | "user" | "assistant" | "tool";
  content: string;
  tool_calls?: ToolCall[];
  tool_call_id?: string;
  name?: string;
};

// ================== 2. 工具注册表 ==================

class ToolRegistry {
  private tools = new Map<string, ToolDefinition>();

  register(tool: ToolDefinition) {
    this.tools.set(tool.name, tool);
  }

  get(name: string): ToolDefinition | undefined {
    return this.tools.get(name);
  }

  list(): ToolDefinition[] {
    return Array.from(this.tools.values());
  }

  // 转换成 OpenAI 风格的 tools schema
  toOpenAISchema(): any[] {
    return this.list().map((tool) => ({
      type: "function",
      function: {
        name: tool.name,
        description: tool.description,
        parameters: tool.parameters,
      },
    }));
  }
}

// ================== 3. 参数校验器(简化版) ==================

class ParameterValidator {
  static validate(args: any, schema: JSONSchema, toolName: string): string[] {
    const errors: string[] = [];

    if (schema.type !== "object" || !schema.properties) {
      return errors;
    }

    // 检查必填项
    for (const key of schema.required ?? []) {
      if (args[key] === undefined || args[key] === null) {
        errors.push(`参数 "${key}" 为必填项`);
      }
    }

    // 检查类型
    for (const [key, propSchema] of Object.entries(schema.properties)) {
      const value = args[key];
      if (value === undefined) continue;

      if (propSchema.type === "string" && typeof value !== "string") {
        errors.push(`参数 "${key}" 必须是字符串`);
      }
      if (propSchema.type === "number" && typeof value !== "number") {
        errors.push(`参数 "${key}" 必须是数字`);
      }
      if (propSchema.type === "boolean" && typeof value !== "boolean") {
        errors.push(`参数 "${key}" 必须是布尔值`);
      }
    }

    return errors;
  }
}

// ================== 4. 工具调用解析器 ==================

class ToolCallParser {
  /**
   * 解析模型返回的 tool_calls JSON。
   * 真实项目里,这部分数据来自 LLM API 的 response.choices[0].message.tool_calls
   */
  static parse(raw: any): ToolCall[] {
    if (!raw || !raw.tool_calls) return [];

    return raw.tool_calls.map((call: any) => ({
      id: call.id,
      name: call.function?.name ?? call.name,
      arguments: call.function?.arguments ?? JSON.stringify(call.input ?? {}),
    }));
  }

  /**
   * 从普通文本里尝试提取 JSON(兜底方案)
   */
  static extractFromText(text: string): ToolCall | null {
    const match = text.match(/\{[\s\S]*\}/);
    if (!match) return null;
    try {
      const parsed = JSON.parse(match[0]);
      if (parsed.tool && parsed.args) {
        return {
          id: "extracted_" + Math.random().toString(36).slice(2),
          name: parsed.tool,
          arguments: JSON.stringify(parsed.args),
        };
      }
    } catch {
      // ignore
    }
    return null;
  }
}

// ================== 5. 工具执行器 ==================

class ToolExecutor {
  constructor(private registry: ToolRegistry) {}

  async execute(call: ToolCall): Promise<{ success: boolean; result: string }> {
    const tool = this.registry.get(call.name);
    if (!tool) {
      return {
        success: false,
        result: `未找到工具 "${call.name}",可用工具:${this.registry
          .list()
          .map((t) => t.name)
          .join(", ")}`,
      };
    }

    let args: any;
    try {
      args = JSON.parse(call.arguments);
    } catch {
      return {
        success: false,
        result: `工具 "${call.name}" 的参数不是合法 JSON:${call.arguments}`,
      };
    }

    // 参数校验
    const errors = ParameterValidator.validate(args, tool.parameters, tool.name);
    if (errors.length > 0) {
      return {
        success: false,
        result: `参数校验失败:${errors.join(";")}。请修正后重试。`,
      };
    }

    try {
      const result = await tool.execute(args);
      return { success: true, result };
    } catch (err: any) {
      return { success: false, result: `执行出错:${err.message}` };
    }
  }
}

// ================== 6. Agent 引擎 ==================

class FunctionCallAgent {
  private messages: Message[] = [];

  constructor(
    systemPrompt: string,
    private registry: ToolRegistry,
    private maxLoops = 3
  ) {
    this.messages = [
      {
        role: "system",
        content:
          systemPrompt +
          "\n\n可用工具列表:\n" +
          JSON.stringify(this.registry.toOpenAISchema(), null, 2),
      },
    ];
  }

  async run(userInput: string): Promise<string> {
    this.messages.push({ role: "user", content: userInput });

    for (let i = 0; i < this.maxLoops; i++) {
      // 调用 LLM(模拟)
      const llmResponse = await this.mockLLM();

      if (llmResponse.content && !llmResponse.tool_calls) {
        this.messages.push({
          role: "assistant",
          content: llmResponse.content,
        });
        return llmResponse.content;
      }

      // 解析 tool_calls
      const toolCalls = ToolCallParser.parse(llmResponse);
      if (toolCalls.length === 0) {
        const extracted = llmResponse.content
          ? ToolCallParser.extractFromText(llmResponse.content)
          : null;
        if (extracted) {
          toolCalls.push(extracted);
        } else {
          return "模型没有返回可执行的工具调用。";
        }
      }

      const executor = new ToolExecutor(this.registry);

      for (const call of toolCalls) {
        console.log(
          `🔧 [Loop ${i + 1}] 调用 ${call.name},参数:`,
          call.arguments
        );

        const { success, result } = await executor.execute(call);
        console.log(`📥 [Loop ${i + 1}] 结果:`, result);

        this.messages.push({
          role: "tool",
          content: result,
          tool_call_id: call.id,
          name: call.name,
        });

        if (!success) {
          // 只要有一个失败就中断,让模型重试
          break;
        }
      }
    }

    return "超过最大工具调用次数。";
  }

  // 模拟 LLM:根据用户输入返回 tool_call 或直接回答
  private async mockLLM(): Promise<Message> {
    const lastUser = [...this.messages]
      .reverse()
      .find((m) => m.role === "user")?.content;

    if (lastUser?.includes("天气")) {
      const city = lastUser.match(/([\u4e00-\u9fa5]+?)(?:的|今天)?天气/)?.[1] ?? "北京";
      return {
        role: "assistant",
        content: null,
        tool_calls: [
          {
            id: "call_001",
            name: "get_weather",
            arguments: JSON.stringify({ city }),
          },
        ],
      };
    }

    if (lastUser?.includes("计算")) {
      const expr = lastUser.replace(/.*计算\s*/, "").replace(/[^0-9+\-*/().]/g, "");
      return {
        role: "assistant",
        content: null,
        tool_calls: [
          {
            id: "call_002",
            name: "calculate",
            arguments: JSON.stringify({ expression: expr }),
          },
        ],
      };
    }

    return {
      role: "assistant",
      content: "你好,我可以查天气或做计算。",
    };
  }
}

// ================== 7. 注册工具并运行 ==================

const registry = new ToolRegistry();

registry.register({
  name: "get_weather",
  description: "查询指定城市的当前天气",
  parameters: {
    type: "object",
    properties: {
      city: {
        type: "string",
        description: "城市名,如北京、上海",
      },
    },
    required: ["city"],
  },
  execute: async ({ city }) => {
    const db: Record<string, string> = {
      北京: "晴天,27℃",
      上海: "多云,29℃",
    };
    return db[city] ?? "未知城市";
  },
});

registry.register({
  name: "calculate",
  description: "执行数学计算表达式",
  parameters: {
    type: "object",
    properties: {
      expression: {
        type: "string",
        description: "合法数学表达式,如 12 * 34 + 5",
      },
    },
    required: ["expression"],
  },
  execute: async ({ expression }) => {
    try {
      return String(eval(expression));
    } catch {
      return "表达式格式错误";
    }
  },
});

(async () => {
  const agent = new FunctionCallAgent(
    "你是一个 helpful 的 Agent,优先使用工具解决用户问题。",
    registry
  );

  console.log("=== 示例 1:天气工具 ===");
  const r1 = await agent.run("北京今天天气怎么样?");
  console.log("🤖 最终回答:", r1);

  console.log("\n=== 示例 2:计算工具 ===");
  const r2 = await agent.run("帮我计算 100 除以 4 再加 3");
  console.log("🤖 最终回答:", r2);
})();

4.2 运行方式

bash 复制代码
npx tsx function-call-engine.ts

4.3 生产环境:替换 mockLLM 为真实 LLM

typescript 复制代码
import OpenAI from "openai";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

const response = await client.chat.completions.create({
  model: "gpt-4o",
  messages: this.messages,
  tools: this.registry.toOpenAISchema(),
  tool_choice: "auto",
});

const choice = response.choices[0].message;
return {
  role: "assistant",
  content: choice.content,
  tool_calls: choice.tool_calls?.map((tc) => ({
    id: tc.id,
    name: tc.function.name,
    arguments: tc.function.arguments,
  })),
};

5. 源码核心底层剖析

5.1 OpenAI Function Call 的完整数据流

OpenAI 的工具调用协议设计得非常"对话化"。我们把工具调用也当作一次 assistant 发言:

text 复制代码
messages = [
  { role: "system", content: "..." },
  { role: "user", content: "北京天气" },
  { role: "assistant", tool_calls: [...] },        // 模型决定调用工具
  { role: "tool", tool_call_id: "...", content: "晴天" }, // 工具返回结果
  { role: "assistant", content: "北京今天晴天" }   // 模型最终回答
]

为什么要用 tool 角色而不是 userassistant

  • 让模型清楚"这是外部世界返回的事实",不是用户输入,也不是模型自己编的。
  • 便于 API 做计费、审计和多轮调用管理。

5.2 参数校验为什么重要?

模型生成参数时可能出错:

  • 类型错误:city: 123(数字而不是字符串)
  • 缺少必填字段:get_weather({})
  • 幻觉参数:get_weather({ city: "北京", lang: "en" })(没有 lang 字段)

如果不校验,直接把参数传给函数,可能导致:

  • 外部 API 返回 400/422。
  • 数据库 SQL 注入风险。
  • 执行意外行为。

所以任何从模型拿到的参数,都要先 JSON Schema 校验

5.3 Tool Call Parser 的两种策略

现代模型 API 都有结构化的 tool_calls 字段。但有些老模型或不支持的模型,会直接把工具调用写进文本里。

所以生产 parser 要做两手准备:

  1. 优先解析 tool_calls 字段。
  2. 兜底从 content 文本里正则/JSON 提取。

5.4 tool_choice 的用法

OpenAI 支持 tool_choice 参数:

  • auto:模型自己决定。
  • none:禁止调用工具。
  • { type: "function", function: { name: "xxx" } }:强制调用某个工具。

强制调用有什么用?

  • 表单 Agent:用户上传文件后,你强制调用 parse_file 工具。
  • 结构化输出:你让模型必须调用 output_json 工具,保证输出格式。

5.5 并行工具调用

OpenAI 等一些模型支持一次返回多个 tool_calls:

json 复制代码
{
  "tool_calls": [
    { "function": { "name": "get_weather", "arguments": "{\"city\":\"北京\"}" } },
    { "function": { "name": "get_weather", "arguments": "{\"city\":\"上海\"}" } }
  ]
}

我们的 ToolExecutorfor...of 串行执行,生产里可以改成 Promise.all 并行执行,加快速度。


6. 常见问题 & 生产踩坑总结

Q1:模型总是不调工具,直接瞎编答案?

检查:

  • 工具 description 是否写清楚了使用场景。
  • system prompt 是否明确"优先使用工具"。
  • tool_choice 是否设为 auto,而不是 none

Q2:模型调了工具但参数是错的?

  • 参数 description 写详细点。
  • required 必填字段。
  • 对枚举类型加 enum 限制。
  • 参数校验失败后,把错误信息喂回模型重试。

Q3:JSON 解析失败,arguments 不是合法 JSON?

常见原因:

  • 模型在 JSON 外面加了 markdown 代码块(json ... )。
  • 模型把多个参数拼错了。

处理:

typescript 复制代码
function cleanArguments(raw: string): string {
  return raw.replace(/```json|```/g, "").trim();
}

Q4:工具执行太慢,前端一直等?

  • 给每个工具调用加超时。
  • 流式输出时,先告诉用户"我正在查..."
  • 异步工具可以返回 job_id,后续轮询结果。

Q5:一个工具失败,整个 Agent 崩溃?

不要直接抛异常。把错误作为 Observation 返回给模型,让它决定下一步。我们代码里就是这样做的。

Q6:工具数量太多,模型选错?

  • 只放当前场景相关的工具(动态工具集)。
  • 给工具分类,先让模型选类别,再选具体工具。
  • 限制每次调用最多 5~10 个工具。

7. 本章小结

核心知识点

  1. Function Call 不是训练出来的能力,而是 LLM 根据工具说明书生成结构化指令。
  2. 工具描述用 JSON Schema,包括 name、description、parameters、required。
  3. Agent 的工作是:发工具说明书 → 解析 tool_calls → 校验参数 → 执行 → 回填 Observation → 循环
  4. 参数校验是必须的,可以防止模型幻觉参数和外部 API 报错。
  5. OpenAI 和 Claude 的数据格式略有差异,但本质相同。
相关推荐
武子康1 小时前
2026 年 7 月 AI 模型发布复盘:真正被比较的是整套工作系统
人工智能·llm·agent
anyup1 小时前
像这种问题千万别自己动手,否则你可太看不起 AI 了
前端·架构·trae
hunterandroid2 小时前
[鸿蒙从零到一] HarmonyOS Web 组件与 JSBridge 通信实战:从页面加载到安全协议
前端
xingren2 小时前
「拍摄器」UI 特效 - 在 Winform/WPF/WinUI3/Avalonia/Web 的实现
前端
leslie1182 小时前
npm常用命令
前端·npm
艾伦野鸽ggg2 小时前
axios 基本操作
前端
阳明山水2 小时前
销量预测2026下半年:新架构、新基准与新共识
人工智能·深度学习·算法·机器学习·架构
不一样的少年_2 小时前
Claude Code 是怎么自己改代码的?答案藏在这 4 个工具里
前端·agent·ai编程
蛋先生DX2 小时前
外挂变内置: 大模型工具调用与思维链的能力进化史
llm·agent