本系列定位:零基础可上手、工程向、可落地实操的 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;
}
这种写法能跑,但有几个大坑:
- 没法单测:输入解析、工具执行、输出生成都混在一起。
- 不好扩展:加一个缓存层、日志层、限流层都很麻烦。
- 不好定位 Bug:一旦某个环节出错,你根本不知道问题出在哪个阶段。
拆成模块后,每个模块只做一件事,像 Lego 积木一样可以替换和扩展。
2.4 传统代码 VS Agent 流水线代码
| 维度 | 传统脚本式 Agent | 模块化流水线 Agent |
|---|---|---|
| 可维护性 | 差,全部耦在一起 | 好,阶段清晰 |
| 可测试性 | 难写单测 | 每个模块都能独立测 |
| 扩展性 | 改一处容易影响全局 | 插拔模块即可 |
| 生产适用性 | Demo 级别 | 企业级 |
| 调试体验 | 找 Bug 靠猜 | 打日志能定位到具体阶段 |
3. 架构流程图 / 原理图
3.1 模块依赖关系
3.2 多轮工具调用时序图
3.3 状态流转图
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. 本章小结
核心知识点
- Agent 不是单函数,而是由 输入解析器、记忆模块、推理规划器、工具执行器、输出生成器 五个模块组成的流水线。
- 执行流程是循环的:输入 → 记忆加载 → 思考决策 → 工具执行 → 观察回填 → (再思考)→ 输出。
- 模块化设计的目的是为了可测试、可扩展、可定位 Bug。
- 工具执行失败不要抛异常,而应把错误作为 Observation 喂回模型,让模型自愈。
- 提示词质量决定模型决策质量,工具描述要写得像 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)。常见后果:
- API 直接报错,提示上下文太长。
- 模型自动截断,把最早的消息丢掉------而最早的消息里往往有最重要的系统提示。
- 模型"失忆",忘记自己的角色和用户的初始需求。
- 成本飙升,因为计费是按 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 上下文窗口工作原理
3.2 三种记忆管理方案对比
3.3 Token 预算控制流程
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,性能差。
优化策略:
- 先用消息条数快速粗筛(滑动窗口)。
- 接近预算时,再精确计算 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. 本章小结
核心知识点
- 上下文窗口是大模型一次能看到的最大 token 数量,超出会溢出或截断。
- Token 是模型处理文本的最小单位,中文和英文的 token 数不同,生产环境要用 tokenizer 精确计算。
- 三种记忆管理方案:滑动窗口(简单)、摘要记忆(保留长期信息)、Token 预算(精细控制成本)。
- System prompt 必须锚定保留,否则模型会忘记角色和规则。
- 长 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 逐一推过来。我们的工作就是:
- 接收模型的 token 流。
- 把每个 token 包装成 SSE 数据块。
- 通过 HTTP 推给前端。
- 前端收到后追加显示。
3. 架构流程图 / 原理图
3.1 Agent 流式输出整体架构
3.2 前端实时渲染流程
3.3 完整时序图
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 怎么运行?
- 初始化项目:
bash
mkdir sse-demo && cd sse-demo
npm init -y
npm install express cors @types/express @types/cors typescript
- 把
server.ts和index.html放到项目目录。 - 编译运行后端:
bash
npx tsx server.ts
- 用浏览器打开
index.html(可以用 VS Code Live Server,或直接用file://打开)。 - 输入"北京天气",看流式打字效果。
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. 本章小结
核心知识点
- 流式输出不是炫技,而是提升用户体验和感知速度的关键。
- SSE 是最适合 LLM 流式输出的协议:单向、基于 HTTP、浏览器原生支持。
- 后端核心工作是:设置 SSE 响应头 → 接收模型 token → 包装成
event/data推给前端。 - 前端核心工作是:用
EventSource或fetch + ReadableStream接收 → 按事件类型处理 → 实时追加渲染。 - 生产要注意:中间件缓冲、心跳保活、停止生成、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 相似度检索。
它的工作方式:
- 把每条记忆(一句话、一段事实)转换成一个高维向量(Embedding)。
- 存在向量数据库里。
- 用户提问时,把问题也转成向量,去库里找最相似的向量。
- 把找到的记忆塞进上下文,帮助模型回答。
这就是 RAG(检索增强生成)的核心思想。
3. 架构流程图 / 原理图
3.1 记忆系统整体架构
3.2 长期记忆向量检索流程
3.3 三种记忆的读写时序
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. 本章小结
核心知识点
- Agent 的记忆分三层:短期记忆(当前上下文) 、会话记忆(本次任务状态) 、长期记忆(跨会话知识)。
- 短期记忆管理窗口,会话记忆管理关键变量,长期记忆用向量检索。
- 长期记忆的底层技术是 Embedding + 向量相似度检索(RAG)。
- 不是所有信息都值得长期保存,要有选择地写记忆。
- 生产环境要注意隐私隔离、检索相关性、成本控制和数据清理。
2.5 Agent 工具调用(Function Call)底层剖析
1. 开篇导读
这部分解决什么问题?
大模型只是一个文本生成器,它怎么知道该调用哪个工具、参数是什么?
很多初学者以为要训练模型才能让它调用工具,其实不是。OpenAI、Claude、通义等模型都支持 Function Call / Tool Use 机制:你告诉模型"有哪些工具、每个工具需要什么参数",模型就能在需要时输出一段结构化的指令,Agent 解析后执行即可。
这一部分我们要深入这个机制的最底层,手写一个工具调用解析器。
学完这部分你能掌握什么?
- 理解 Function Call 的完整数据格式。
- 写出工具注册、参数校验、函数调用的完整代码。
- 掌握 OpenAI
tool_calls和 Claudetool_use的差异。 - 处理"模型输出非标准 JSON"、"参数缺失"、"选错工具"等生产问题。
适合什么场景?
- 任何需要 Agent 调用外部 API 的场景。
- 自定义工具平台、MCP 协议对接。
- 表单 Agent、代码 Agent、数据分析 Agent。
- 做多 Agent 协作时的工具编排。
2. 核心原理通俗讲解
2.1 大模型是怎么决定调用工具的?
你可以把 Function Call 理解成"给模型发一份产品说明书"。
模型拿到说明书后,会做两步判断:
- 需不需要用工具?
- 如果用,用哪一个、参数怎么填?
比如你给模型的说明书是:
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 工具调用整体流程
3.2 Agent 与工具的关系
3.3 一次完整工具调用的时序
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 角色而不是 user 或 assistant?
- 让模型清楚"这是外部世界返回的事实",不是用户输入,也不是模型自己编的。
- 便于 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 要做两手准备:
- 优先解析
tool_calls字段。 - 兜底从
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\":\"上海\"}" } }
]
}
我们的 ToolExecutor 用 for...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. 本章小结
核心知识点
- Function Call 不是训练出来的能力,而是 LLM 根据工具说明书生成结构化指令。
- 工具描述用 JSON Schema,包括 name、description、parameters、required。
- Agent 的工作是:发工具说明书 → 解析 tool_calls → 校验参数 → 执行 → 回填 Observation → 循环。
- 参数校验是必须的,可以防止模型幻觉参数和外部 API 报错。
- OpenAI 和 Claude 的数据格式略有差异,但本质相同。