Skip to content
给 Agent 添加记忆与上下文管理
无记忆 Agent 在连续对话中的失效表现
LLM 的 API 在设计上是无状态的。每次调用 chat.completions.create,模型看到的只是一个 messages 数组,不会自动记住上一次调用的内容。如果每次请求都只发送当前这条用户消息,Agent 对对话历史一无所知。
用一个最简场景观察:
typescript
import OpenAI from 'openai';
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
async function ask(question: string) {
const resp = await client.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: question }],
});
console.log(resp.choices[0].message.content);
}
await ask('我叫小明。');
await ask('我叫什么名字?');第一次调用后,模型按要求回应。第二次调用时,messages 中只有 '我叫什么名字?',它看不到"我叫小明"这条历史,于是会给出一个猜测或直接说明不知道。
这就是无记忆 Agent 的问题:工具调用能力再强,只要丢失历史上下文,就退化成一个健忘的对话机器人。对于一个需要多轮交互完成任务的 Agent 来说,上一轮返回的数据、刚刚计算出的结果、用户确认过的条件全部消失后,推理链条就会断开。
解决思路是明确的:应用层显式维护消息历史,每次请求都把完整历史与当前消息一起发给模型。这也是短期记忆的核心工程形态。
消息历史建模:messages 数组的存储与更新
OpenAI 兼容的 Chat Completion 接口使用 messages 数组表达对话全貌。这个数组就是 Agent 短期记忆的物理载体。
角色字段与消息序列
数组中的每个元素是一条消息对象,核心字段是 role 和 content(或 tool_calls)。四种基本角色:
system:设定 Agent 的行为边界、回答格式、工具使用规范。一般只放在数组最前面。user:用户的每一次输入。assistant:模型的回复,可能包含纯文本content,也可能包含tool_calls声明。tool:工具执行完毕返回给模型的结果,必须携带tool_call_id,与对应assistant消息中tool_calls[].id匹配。
下面是一次简单的工具调用在 messages 数组中的完整形态:
typescript
const messages: Array<any> = [
{ role: 'system', content: '你是一个计算助手。' },
{ role: 'user', content: '3 加 5 等于多少?' },
// 第一次模型响应:要求调用 calculator 工具
{
role: 'assistant',
content: null,
tool_calls: [{
id: 'call_abc123',
type: 'function',
function: { name: 'calculator', arguments: '{"a":3,"b":5,"op":"add"}' },
}],
},
// 工具执行结果
{
role: 'tool',
tool_call_id: 'call_abc123',
content: '8',
},
// 模型最终回复(基于工具结果)
{
role: 'assistant',
content: '3 加 5 等于 8。',
},
];Agent 需要在自己内部维护这样一份数组,每次收到用户新输入时追加一条 user 消息,每次收到模型的完整响应后追加一条 assistant 消息(以及可能的 tool 消息)。下一次调用 LLM 时,将整份数组作为请求体发送。
更新规则:
- 初始化时创建数组,第一条消息必须是
system。 - 用户发言:往数组末尾 push 一个
{ role: 'user', content }。 - 模型返回
assistant消息:如果包含tool_calls,push 时保留tool_calls字段;文本回答直接 push{ role: 'assistant', content }。 - 每执行一个工具调用后,立即 push
{ role: 'tool', tool_call_id, content }。如果模型在一次响应中请求多个工具,可以并行执行,但 push 顺序一般与工具调用列表顺序一致,不影响正确性。 - 模型生成最终回答后,这轮对话结束。
工具调用结果在历史中的位置
工具结果的位置不是可选的。模型在看到 tool 消息之前,不会承认工具已执行。如果错误地将工具结果以 user 角色回传,模型可能把它当成用户的新输入,导致行为异常。记忆模块必须严格维护 tool 角色并匹配 tool_call_id。
Token 感知:精确计数与剩余容量追踪
维护完整历史很快会遇上 Context Window 天花板。要安全地管理历史,Agent 需要知道自己当前已经占用了多少 token,还剩多少容量。
使用 tiktoken 计算 token 数量
tiktoken 是一个字节对编码分词器,提供与 OpenAI 模型一致的分词逻辑。安装后在 Node.js 中使用:
typescript
import { encodingForModel } from 'tiktoken';
const enc = encodingForModel('gpt-4o');
function countTokens(messages: Array<any>): number {
let total = 0;
for (const msg of messages) {
let text = '';
if (typeof msg.content === 'string') {
text += msg.content;
}
if (msg.tool_calls) {
text += JSON.stringify(msg.tool_calls);
}
total += enc.encode(text).length;
}
total += 3; // 每次请求的对话前缀开销,粗略估计
return total;
}在构造请求前调用 countTokens(messages),即可得到当前上下文大小的近似值。如果需要更精确的数值,可以参考 API 响应中的 usage.prompt_tokens,但那是事后数据,只能用于下一次决策。
对于 GPT-4o,实际计算比这个复杂——每条消息有固定 token 开销,name 字段、工具定义等都可能被计入。上面的简化版本在消息数量不过百时偏差不大。如果要严格防止越界,可以预留 50-100 token 的安全余量。
模型上下文窗口大小通过查阅文档获得,例如 gpt-4o 为 128k token,gpt-4o-mini 也是 128k。Agent 运行时应持有 maxContextTokens 参数。
从 API usage 字段获取实际消耗
每次 API 调用返回的 usage 对象中,prompt_tokens 就是模型实际看到的输入 token 数,completion_tokens 是输出 token 数。可以用它校准 tiktoken 的计算,或者直接以最近一次返回的 prompt_tokens 作为调整依据:
typescript
const resp = await client.chat.completions.create({ model, messages });
console.log(resp.usage?.prompt_tokens); // 模型实际消耗在滑动窗口截断或摘要触发逻辑中,可以使用 tiktoken 预计算,也可以用上次响应的 prompt_tokens 来近似当前历史大小。预计算成本更低且不依赖网络。
滑动窗口截断:保留最新消息
当历史消息的 token 数逼近窗口上限时,最简单直接的策略是删掉最旧的消息,只保留最新的若干轮。
截断函数的实现
基本逻辑:始终保留 system 消息,然后从后往前保留消息,直到累计 token 超过给定上限,前面的全部丢弃。
typescript
function slidingWindowTruncate(
messages: Array<any>,
maxTokens: number,
countTokensFn: (msgs: Array<any>) => number,
): Array<any> {
const sysMsg = messages.find((m) => m.role === 'system');
const nonSys = messages.filter((m) => m.role !== 'system');
if (countTokensFn(messages) <= maxTokens) return messages;
const kept: Array<any> = [];
let tokenCount = sysMsg ? countTokensFn([sysMsg]) : 0;
for (let i = nonSys.length - 1; i >= 0; i--) {
const candidate = nonSys[i];
const trial = [candidate, ...kept];
const trialTokens = countTokensFn([sysMsg, ...trial].filter(Boolean));
if (tokenCount + trialTokens > maxTokens) break;
kept.unshift(candidate);
tokenCount = trialTokens;
}
return sysMsg ? [sysMsg, ...kept] : kept;
}maxTokens 通常设置为 maxContextTokens - 预期输出最大 token 数,取上下文的 80%~90%。
避免切断运行中的工具链
滑动窗口直接按单条消息截断存在一个风险:在工具调用过程中,assistant 包含 tool_calls 的消息和后续的 tool 消息是成对出现的。如果截断时 assistant 消息被丢弃而对应的 tool 消息保留,模型会看到孤立的 tool 消息,可能报错或产生幻觉。
截断函数需要保证"工具调用往返"的完整性。改进方式:在从后往前扫描时,一旦遇到一条 role: 'tool' 的消息,就需要保证其对应的 assistant(含有相同 tool_call_id 的 tool_calls)也被保留。简化处理:如果发现即将丢弃的某条消息是带有 tool_calls 的 assistant,则连同它后面的 tool 消息组一起保留。
更稳妥的做法是将一组"assistant 工具请求 + 对应 tool 结果 + 最终 assistant 回复"视为一个原子块,截断时只丢弃完整的块。
摘要压缩:用 LLM 浓缩历史
滑动窗口简单直接,但在长任务中会丢失较早的重要信息——比如用户最初的目标、关键的中间计算结果。这时可以引入摘要压缩:让 LLM 将旧历史提炼成一段短文,用它替代大量原始消息。
触发条件与摘要内容设计
触发摘要的常见条件:当前消息总 token 数超过上下文窗口的 70%。也可以结合轮数阈值。
摘要的核心内容是任务目标、已进行的步骤、关键事实、待完成的事项。不需要复制整段对话,而是提取对后续推理仍然必要的信息。
下面的实现演示了如何将早期消息替换为一条摘要消息:
typescript
async function summarizeHistory(
messages: Array<any>,
client: OpenAI,
model: string,
maxSummaryTokens: number = 1024,
): Promise<Array<any>> {
const sysMsg = messages.find((m) => m.role === 'system');
// 取前一半作为待摘要部分,后一半保留原文
const mid = Math.floor(messages.length / 2);
const toSummarize = messages.slice(0, mid);
const recent = messages.slice(mid);
const summaryPrompt = `
请总结以下对话历史,保留任务目标、关键事实和当前进展,不要省略数字、日期和决策。
对话历史:
${JSON.stringify(toSummarize)}
总结:`;
const summaryResp = await client.chat.completions.create({
model,
messages: [{ role: 'user', content: summaryPrompt }],
max_tokens: maxSummaryTokens,
temperature: 0.2,
});
const summaryText = summaryResp.choices[0].message.content ?? '';
// 用一条 assistant 消息替代被摘要的部分
const summaryMsg = { role: 'assistant', content: `[对话摘要] ${summaryText}` };
return sysMsg ? [sysMsg, summaryMsg, ...recent] : [summaryMsg, ...recent];
}这里的 toSummarize 段在传给模型时直接以 JSON 字符串的形式填入提示词。如果该段落非常长,本身就会超过模型上下文,需要先做一次粗粒度的滑动窗口截断再送入摘要提示词。
摘要生成后可显著缩减 token 占用,但会丢失细节。设计摘要提示词时需要特别强调保留数字、名称、状态、未完成目标等信息。对于正在进行中的工具调用链,不能在中间截断——必须在触发摘要前检查当前消息尾部是否存在未完成的工具往返,如有,则等待工具结果返回后再压缩。
用摘要替换旧消息的实现
替换动作:删除被摘要部分的所有消息,在保留部分的头部插入一条摘要消息。摘要消息的 role 可设为 assistant,也可以设为专门的 system 级提示片段,但放在 assistant 里不影响模型行为。考虑到未来可能需要对摘要进行多级压缩,将其视为一种特殊的 assistant 消息是合理的。
集成记忆到 Agent 执行循环
有了上面的基础组件,就可以把记忆管理嵌入到 Agent 执行循环中。
基于执行循环的扩展点
在可复用循环中,每次迭代大致做:构造 messages → 调用 LLM → 处理 tool_calls 或终止。接入记忆后,在"构造 messages"这一步需要动态从记忆模块取出当前上下文,在"调用 LLM"前可能先触发截断或摘要。
一个集成了记忆管理的执行器:
typescript
class MemoryAgent {
private history: Array<any> = [];
private maxTokens: number;
private summaryThreshold: number;
private client: OpenAI;
private model: string;
constructor(opts: {
client: OpenAI;
model: string;
maxTokens: number;
summaryThreshold?: number;
systemPrompt: string;
}) {
this.client = opts.client;
this.model = opts.model;
this.maxTokens = opts.maxTokens;
this.summaryThreshold = opts.summaryThreshold ?? 0.7;
this.history = [{ role: 'system', content: opts.systemPrompt }];
}
async run(userInput: string, tools?: Array<any>): Promise<string> {
this.history.push({ role: 'user', content: userInput });
// 上下文管理
this.history = this.manageContext(this.history);
const resp = await this.client.chat.completions.create({
model: this.model,
messages: this.history,
tools,
});
const choice = resp.choices[0];
const assistantMsg = choice.message;
this.history.push(assistantMsg as any);
if (assistantMsg.tool_calls?.length) {
// 执行工具并将结果以 tool 角色 push
// 再次调用模型,最终结果再 push
// 管理上下文可能在此过程中再次触发
}
return assistantMsg.content ?? '';
}
private manageContext(messages: Array<any>): Array<any> {
let current = messages;
const tokenCount = countTokens(current);
if (tokenCount > this.maxTokens * this.summaryThreshold) {
current = slidingWindowTruncate(current, this.maxTokens * 0.9, countTokens);
if (countTokens(current) > this.maxTokens * this.summaryThreshold) {
// 仍然过高,使用摘要
}
}
return current;
}
}记忆模块与执行循环耦合的地方主要是两处:每次调用 LLM 前从 this.history 取出经过截断/摘要的消息列表;每次模型返回或工具执行后将结果写入历史。这层抽象可以进一步抽离成 MemoryManager 类,执行循环只通过接口读写。
完整示例:支持多轮对话的 Agent
下面是一个可运行的多轮对话 Agent,集成记忆、滑动窗口和摘要。工具调用沿用 calculator 工具,摘要的异步操作在 manageContext 中调用:
typescript
import OpenAI from 'openai';
import { encodingForModel } from 'tiktoken';
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const MODEL = 'gpt-4o';
const MAX_CONTEXT_TOKENS = 128000;
const SUMMARY_THRESHOLD = 0.7;
const enc = encodingForModel(MODEL);
function countTokens(messages: Array<any>): number {
let total = 0;
for (const msg of messages) {
let text = '';
if (typeof msg.content === 'string') text += msg.content;
if (msg.tool_calls) text += JSON.stringify(msg.tool_calls);
total += enc.encode(text).length;
}
return total + 3;
}
function slidingWindowTruncate(
messages: Array<any>,
maxTokens: number,
): Array<any> {
const sysMsg = messages.find((m) => m.role === 'system');
const nonSys = messages.filter((m) => m.role !== 'system');
if (countTokens(messages) <= maxTokens) return messages;
const kept: Array<any> = [];
let tokenCount = sysMsg ? countTokens([sysMsg]) : 0;
for (let i = nonSys.length - 1; i >= 0; i--) {
const candidate = nonSys[i];
const trialTokens = countTokens([...kept, candidate]);
if (tokenCount + trialTokens > maxTokens) break;
kept.unshift(candidate);
tokenCount += countTokens([candidate]);
}
return sysMsg ? [sysMsg, ...kept] : kept;
}
class MemoryAgent {
private history: Array<any> = [];
private tools: Array<any>;
constructor(systemPrompt: string, tools?: Array<any>) {
this.history = [{ role: 'system', content: systemPrompt }];
this.tools = tools ?? [];
}
async chat(userMessage: string): Promise<string> {
this.history.push({ role: 'user', content: userMessage });
this.applyContextManagement();
let messages = this.history;
while (true) {
const resp = await client.chat.completions.create({
model: MODEL,
messages,
tools: this.tools.length > 0 ? this.tools : undefined,
});
const assistantMsg = resp.choices[0].message;
this.history.push(assistantMsg as any);
if (assistantMsg.tool_calls && assistantMsg.tool_calls.length > 0) {
for (const tc of assistantMsg.tool_calls) {
const fn = tc.function;
let result = '';
if (fn.name === 'calculator') {
const { a, b, op } = JSON.parse(fn.arguments);
switch (op) {
case 'add': result = String(a + b); break;
case 'multiply': result = String(a * b); break;
default: result = 'unknown operation';
}
}
this.history.push({
role: 'tool',
tool_call_id: tc.id,
content: result,
});
}
messages = this.history;
continue;
}
return assistantMsg.content ?? '';
}
}
private applyContextManagement() {
const tokenCount = countTokens(this.history);
if (tokenCount > MAX_CONTEXT_TOKENS * SUMMARY_THRESHOLD) {
this.history = slidingWindowTruncate(
this.history,
MAX_CONTEXT_TOKENS * 0.9,
);
}
}
}
(async () => {
const agent = new MemoryAgent(
'你是计算助手,可以使用 calculator 工具。请记住用户的请求和历史计算结果。',
[
{
type: 'function',
function: {
name: 'calculator',
description: '执行加法和乘法运算',
parameters: {
type: 'object',
properties: {
a: { type: 'number' },
b: { type: 'number' },
op: { type: 'string', enum: ['add', 'multiply'] },
},
required: ['a', 'b', 'op'],
},
},
},
],
);
console.log(await agent.chat('我叫小明。计算 12 * 8'));
console.log(await agent.chat('我刚刚计算的第一个数字是多少?'));
console.log(await agent.chat('把我名字和计算结果写在一起'));
})();运行这个脚本,Agent 在第二轮中能说出"12",第三轮能结合名字和计算结果输出。如果去掉记忆(只发送当前 user 消息),第二轮就无法回答出数字。
多轮对话行为验证与效果对比
可以通过对比脚本验证有记忆和无记忆版本的输出。无记忆版本每次请求只有一条 user 消息,有记忆版本使用上面的 MemoryAgent。将上下文窗口人为降低(例如设置 MAX_CONTEXT_TOKENS = 500)并填充一些无关对话,可以观察到截断生效后较早的信息丢失,但最新的对话仍然连贯。
摘要的效果验证需要模拟长对话,并在日志中打印摘要前后的历史 token 数和内容。在一次包含多次计算和闲聊的对话中,token 超过阈值后触发摘要,后续消息中出现了 [对话摘要] 的标记,并且模型仍然能回答基于早期任务的问题。
注意点与限制
- tiktoken 编码器匹配:
encodingForModel需要传入正确的模型名,对不支持的模型会报错。如果接入其他厂商的兼容接口,分词器可能不同,需改用对应的tiktoken编码或厂商提供的工具,或者简单地用字符长度估算。 - 截断逻辑与工具链的冲突:滑动窗口实现如果不够谨慎,会留下孤立的
tool消息,导致下一次 API 调用失败(400 错误)。建议将完整的"工具调用 → 工具结果 → 模型回复"视为不可分割的原子块。如果原子块本身已经超过窗口大小,只能考虑摘要或中断任务。 - 摘要丢失精确数据:LLM 生成的摘要无法保证数字和关键字的完美还原。对于需要严格依赖历史数值的任务,摘要压缩存在风险。可以混合策略:对数值型关键数据单独存一份,摘要中只保留语义描述。
- 摘要调用的额外成本:每次摘要生成会额外消耗一次 LLM 调用,可以使用更便宜、更快的模型(如
gpt-4o-mini)执行摘要。 - 上下文窗口设置:
maxContextTokens必须小于模型文档声明的值,且需要为输出留出足够的 tokens。如果maxContextTokens设为大于窗口本身的值,截断函数可能返回空数组,导致调用失败。应在初始化时做边界检查。 - 多轮后的工具调用:记忆模块截断或摘要后,模型可能失去对已注册工具的上下文,但 tool definitions 在请求中始终完整,所以工具可用性不受影响。不过如果摘要丢弃了工具调用的结果,模型后续可能无法正确推理该结果与当前任务的关系。
- 记忆深度与响应速度:过长的历史会让模型推理变慢、偏离主题,需要根据应用场景权衡。
