
作为写代码的人这两年最深的感受是AI Agent 的门槛根本不在会调大模型接口而在于你怎么让它记住该记住的、忘掉该忘掉的。前阵子我用 Node.js LangChain 从零搭了一个带上下文压缩的个人助理 Agent过程中踩了不少坑也把上下文管理这件事彻底搞明白了。这篇文章就从零开始完整复盘整个项目重点拆解上下文压缩这套核心机制。无论你是想入门 AI Agent 开发还是准备在生产环境优化长对话体验这篇都值得花十分钟看完。1. 项目定位与方案选型动手写代码之前先把项目想清楚。很多人一上来就装依赖、写 Agent结果做着做着发现需求变了、技术栈选得别扭返工成本极高。我这边的经验是先花半小时做方案设计后面能省一天时间。1.1 个人助理 Agent 的功能边界我给自己定的目标是做一个个人助理具体能力限定在三个场景安排日程、记录备忘、基于已有信息的问答。它需要支持连续多轮对话比如用户说帮我安排明天下午3点的产品评审Agent 要能调用日程工具创建事件然后基于工具返回结果生成回复。注意我刻意没做多复杂的事。很多教程一上来就是 ReAct 模式加十几个工具看起来很炫但真正跑起来你会发现工具越多模型在决策时越容易选错调试难度也指数级上升。个人项目先把三五个工具做扎实再考虑扩展。需求拆解下来这个系统需要四个核心模块对话理解模型、工具调用函数执行、记忆管理上下文保存、上下文压缩容量控制。1.2 技术栈选型为什么是 Node.js LangChainPython 在 AI 领域确实生态最全但现在的 LLM 应用开发和传统 ML 训练完全是两码事。模型跑在云端我们只是通过 API 调用这时候语言本身的能力差异就体现出来了。Node.js 的优势很明显异步 I/O 天然适合 API 密集调用场景npm 生态对工具链支持好写出来的服务可以和后端直接打通部署也轻量。我本来只是拿它做个实验性质的小项目结果顺手就把它跟前端页面和定时任务都串起来了开发效率确实高。LangChain 这边的选择逻辑是这样的它把调模型写提示词调工具这些重复性工作抽象成了模块化组件像ChatOpenAI、tool()、Message这些开箱即用特别适合快速验证想法的场景。1.3 LangChain 还是 LangGraph新手的第一个困惑我发现很多入门者卡在这一步搞不清 LangChain 和 LangGraph 到底什么关系。简单说LangChain 是组件库负责提供模型封装、工具定义、消息结构这些基础能力LangGraph 是编排框架负责让 Agent 进入观察-思考-行动的循环管理状态流转。用生活类比来解释LangChain 是工具箱扳手、螺丝刀、测量尺都给你备好了LangGraph 是流水线设计图规定了你应该先拧螺丝还是先装轮子。早期 LangChain 自带 AgentExecutor 来做编排后来官方把重心转向 LangGraph很多教程没跟上导致网上语言混乱。我的做法很简单依赖该用 LangChain 的用 LangChainAgent 循环直接用 LangGraph 的createReactAgent省去自己写 while 循环的麻烦也保证状态管理不出错。2. 环境准备与最小链路打通这个环节的目标只有一个让你好能回复出来。先不管 Agent、工具这些花哨的东西把模型调用链路跑通后面所有问题都好定位。2.1 Node.js 环境准备LangChain.js 目前要求 Node.js 18 以上我这边直接装的是 20 LTS。建议用 nvm 管理 Node 版本避免以后项目多了互相打架。curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash nvm install 20 nvm use 20 node -v这里有个真实踩过的坑Node 18 早期版本有node:util模块导出不完整的问题部分依赖会启动时直接报错。如果你用的是 18确保小版本在 18.17 以上或者干脆上 20。2.2 初始化项目与安装依赖依赖安装这块有版本讲究。LangChain 生态经历过一次大拆分很老教程里的langchain单包全能用法已经变了。现在的主流做法是按需安装mkdir personal-agent cd personal-agent npm init -y npm install langchain/openai langchain/core langchain/langgraph npm install dotenv js-tiktoken zod npm install -D types/node typescript逐个解释一下这些包的角色langchain/openai管模型接入langchain/core提供消息和工具的基础类型langchain/langgraph提供 Agent 编排能力js-tiktoken负责 token 数计算zod做工具参数的 schema 校验。2.3 模型接入与首次对话验证模型接入本身不复杂但环境变量的管理从一开始就要规范。我习惯用.env文件存密钥然后通过 dotenv 加载// .env OPENAI_API_KEYsk-xxxxx OPENAI_MODELgpt-4o-miniimport dotenv/config; import { ChatOpenAI } from langchain/openai; const model new ChatOpenAI({ modelName: process.env.OPENAI_MODEL || gpt-4o-mini, temperature: 0, }); const resp await model.invoke(你好用一句话介绍你自己); console.log(resp.content);跑通这一步说明模型链路没问题接下来就可以研究 Agent 了。我建议把这段代码存成test-model.js后面排查问题时经常要用到。3. Agent 核心机制模型、工具与记忆这一步是从聊天机器人跨到Agent的关键一步也是很多人概念最模糊的地方。我尽量把抽象的东西讲具体。3.1 从对话机器人到Agent的关键跃迁普通聊天机器人是一问一答收到用户消息生成回复结束。Agent 则是一个循环先理解用户的意图判断是否需要调用外部工具如果需要就执行工具把结果返回给模型让模型基于工具结果再生成回复。举个例子。用户说明天下午3点帮我安排发布会普通聊天机器人只会回复好的已记录但它并没有真正写入日程Agent 则会触发日程工具写入日历然后告诉用户已成功安排。这个循环在 LangGraph 的createReactAgent里是自动完成的。它会检查模型输出中是否有tool_calls有就执行对应工具把工具结果以 ToolMessage 的形式追加到对话里再继续交给模型。整个流程对开发者来说是黑盒你要做的是把工具定义对、把上下文喂对。3.2 让 Agent 拥有手脚工具定义与注册工具定义是 Agent 开发中代码量占比不小的地方也是决定 Agent 能力上限的关键。一个工具的本质是告诉模型你能干什么、调用需要哪些参数、参数是什么格式然后模型在需要时以 JSON 格式发起调用。LangChain.js 里用tool()函数定义工具用 zod 声明参数结构import { tool } from langchain/core/tools; import { z } from zod; const scheduleTool tool( async ({ title, time }) { // 实际项目里这里会写数据库或调用日历 API const eventData { title, time, createdAt: new Date().toISOString() }; return 日程创建成功${title}时间${time}; }, { name: create_schedule, description: 为用户创建一条日程提醒需要提供日程标题和时间, schema: z.object({ title: z.string().describe(日程标题), time: z.string().describe(日程时间格式 YYYY-MM-DD HH:mm), }), } );关键点在于description字段。这是模型决定该不该调用这个工具的核心依据写得越具体模型判断越准确。我见过很多人写创建日程四个字就完事了结果模型在用户问天气时都不调工具全在瞎猜。3.3 记忆与上下文两个被搞混的概念很多新人把记忆和上下文当成一回事这是隐患的起点。我个人的定义是上下文每一次请求发给模型的消息集合记忆Agent 对历史信息的存储和管理能力它决定上下文里放什么换句话说上下文是快照记忆是仓库。快照要从仓库里取数据但仓库不可能无限堆所以需要压缩策略让快照始终维持在模型可处理、成本可接受的规模。在 LangChain 里记忆的最简单形式就是把历史消息保存在数组里下次请求原样带上。这种做法在 demo 里没问题但对话一旦变长token 会迅速膨胀。这时候上下文压缩就登场了。3.4 为什么上下文压缩是刚需直接说数据。我项目的系统提示词加工具定义大约占 800 token每轮用户 助手的对话在中文场景下轻松消耗 300-600 token。GPT-4o-mini 的上下文窗口虽然有 128K但真实项目里不会让你敞开了用第一是成本。按输入 $0.15/1M token 算一次 20K token 的请求单轮成本大概是 0.003 美元看起来不多但 Agent 内部可能要 3-5 次模型调用乘以每天几千次请求开销就跑起来了。第二是延迟。请求 token 越多首字响应越慢。这是 Transformer 的架构特性上下文过长时计算量线性增长用户体验直线下降。第三是效果。超过一定长度后模型对早期信息的注意力会衰减术语叫 lost in the middle。实测 10 轮对话以后如果不做压缩Agent 经常忘记用户最开始说的关键要求。所以上下文压缩不是锦上添花而是 Agent 从 demo 走向可用的必经之路。4. 上下文压缩的完整实现这一章是整篇文章的核心。我会先讲清楚 token 从哪里消耗再对比几种压缩方案最后给出一个能直接用于生产的混合压缩实现。4.1 先算清楚上下文里的 Token 都去哪了一次 Agent 请求的上下文由四个部分组成系统提示词角色设定、工作规则固定开销工具定义每个工具的 name、description、schema 都会被序列化塞给模型工具越多越占对话历史之前的 user、assistant、tool 消息当前输入用户最新的问题其中对话历史是最膨胀的部分而且是线性增长的。我实测过一个中文场景用户每轮输入平均 150-250 字约合 200-300 token助手回复平均 200-400 字约合 300-500 token如果中间有工具调用还要追加 tool 消息。算下来一轮完整对话平均要吃掉 800-1000 token。这意味着如果预算 3K token 给历史消息最多只能装 3-4 轮完整对话。如果不压缩对话到第 10 轮时光历史就超过 8K token再翻几倍就会逼近模型窗口上限。4.2 四种常用压缩策略对比我把市面上常见的方案整理成一张表方便对照选型策略核心思路优点缺点适用场景滑动窗口只保留最近 N 轮原始消息实现简单无额外开销早期重要信息会丢失短会话/简单问答摘要压缩把早期对话交给模型生成摘要信息保留度高上下文利用率好需要额外模型调用有算力成本大多数多轮对话场景混合压缩摘要 滑动窗口结合平衡信息完整度和成本实现复杂度较高生产环境首选向量检索历史消息向量化按相关性召回可检索任意早期信息需要向量库实现重知识库型 Agent个人项目我推荐第二种或第三种。第二种足够简单先在对话轮数上设个阈值触发后压缩一次第三种是我最终采用的方案下面详细讲。4.3 滑动窗口 摘要的混合压缩实现我的设计思路是这样的对话历史分两层管理——一个短期缓存保存最近的原始消息一个长期归档保存已经压缩过的摘要。当短期缓存达到一定规模就把最旧的一部分消息抽出来生成摘要合并进长期归档短期缓存只保留最近的消息。具体实现我封装了一个 ConversationMemory 类import { ChatOpenAI } from langchain/openai; import { SystemMessage, HumanMessage, AIMessage, ToolMessage } from langchain/core/messages; import { encoding_for_model } from js-tiktoken; const enc encoding_for_model(gpt-4o); function estimateTokens(text) { return enc.encode(text).length; } export class ConversationMemory { constructor({ maxRecentMessages 20, maxHistoryTokens 3000, summaryModel }) { this.recent []; this.summary ; this.maxRecentMessages maxRecentMessages; this.maxHistoryTokens maxHistoryTokens; this.summaryModel summaryModel; } add(message) { this.recent.push(message); this.trimRecent(); } getMessages() { const messages []; if (this.summary) { messages.push(new SystemMessage( 以下是更早对话的摘要请作为背景信息参考\n${this.summary} )); } messages.push(...this.recent); return messages; } estimateTotalTokens() { const recentText this.recent.map(m m.content).join(\n); const summaryText this.summary; return estimateTokens(recentText) estimateTokens(summaryText); } trimRecent() { if (this.recent.length this.maxRecentMessages) { this.compress(this.maxRecentMessages).catch(console.error); } } async compress(keepCount) { const toSummarize this.recent.slice(0, -keepCount); const toKeep this.recent.slice(-keepCount); if (toSummarize.length 0) return; const newSummary await this.generateSummary(toSummarize); this.summary this.summary ? ${this.summary}\n${newSummary} : newSummary; this.recent toKeep; } async generateSummary(messages) { const text messages .map(m { const role m instanceof HumanMessage ? 用户 : m instanceof AIMessage ? 助手 : 工具; return ${role}: ${m.content}; }) .join(\n); const resp await this.summaryModel.invoke([ new SystemMessage(你是对话压缩引擎。请将下面的对话压缩成简洁摘要保留用户的明确要求、已确认的关键信息、工具调用结果、待办事项。直接输出摘要正文不要任何开场白控制在300字以内。), new HumanMessage(text), ]); return resp.content; } }这里的核心逻辑在compress()当短期缓存超过阈值取最旧的一部分做摘要摘要进入 long-term summary最新的一部分保留原文。这样既保留了近期对话的完整细节又不会让长期历史无限膨胀。token 估算用的是js-tiktoken这是 OpenAI 官方分词器的 JS 移植版能准确计算 GPT-4 系列模型的 token 数。注意不同模型的 tokenizer 不完全相同但 GPT-4、GPT-4o 这类模型的差异很小可以共用。4.4 压缩触发条件与参数调优压缩不能等到上下文快爆了才动手那样模型已经受影响了。合理做法是设置一个预警水位线当 token 用量或消息条数超过阈值就触发压缩。我项目里的参数是这样的短期缓存最多 20 条消息历史 token 预算 3000每次压缩取最早的一半消息做摘要保留下半部分原文摘要模型用同一个 ChatOpenAI但 temperature 设为 0保证输出稳定这里有一个容易忽视的点摘要本身也有 token 成本而且会累积。你第一次生成 200 token 的摘要第二次压缩又要把这个摘要和新的消息一起再生成一次摘要摘要会越来越大。所以我在摘要生成时明确要求300字以内并且在 merge 时做了简单拼接。如果严格一点可以考虑对摘要再套一层压缩逻辑让总摘要长度保持在上限以内。还有一个经验值要分享中文场景的 token 消耗比英文厉害得多。英文平均 4 个字符 1 个 token中文大约 1 个汉字就接近 1 个 token同样的信息量中文要多花 30%-50% 的 token。所以面向中文用户的 Agent上下文预算要适当放宽。5. 完整实战跑通带上下文压缩的个人助理 Agent理论说完了上真实代码。我把整个项目串成一个可以直接运行的 Agent包含两个工具、上下文压缩、LangGraph 编排。5.1 项目结构梳理最终目录结构如下personal-agent/ ├── .env ├── package.json ├── tools.js # 工具定义 ├── memory.js # 上下文压缩记忆模块 ├── agent.js # Agent 构建 └── index.js # 入口命令行交互这种拆分方式的好处是职责清晰工具独立成文件方便以后添加新工具内存压缩单独一个模块核心逻辑不和其他代码耦合。如果你想升级成 Web 服务入口文件换掉就行。5.2 核心代码实现先从工具定义开始// tools.js import { tool } from langchain/core/tools; import { z } from zod; export const scheduleTool tool( async ({ title, time }) { const eventData { title, time, createdAt: new Date().toISOString() }; // 这里应该写数据库示例中直接拼接结果返回 return 日程创建成功${title}时间${time}; }, { name: create_schedule, description: 为用户创建一条日程提醒需要提供日程标题和时间, schema: z.object({ title: z.string().describe(日程标题如产品评审), time: z.string().describe(日程时间格式 YYYY-MM-DD HH:mm), }), } ); export const memoTool tool( async ({ content }) { // 示例直接返回实际场景可以写本地文件或数据库 return 备忘录已保存内容${content}; }, { name: save_memo, description: 保存一条备忘录用于记录用户的临时想法或待办事项, schema: z.object({ content: z.string().describe(备忘录内容), }), } );然后创建 Agent 实例// agent.js import { ChatOpenAI } from langchain/openai; import { createReactAgent } from langchain/langgraph/prebuilt; import { MemorySaver } from langchain/langgraph; import { scheduleTool, memoTool } from ./tools.js; const model new ChatOpenAI({ modelName: process.env.OPENAI_MODEL || gpt-4o-mini, temperature: 0, }); export const agent createReactAgent({ llm: model, tools: [scheduleTool, memoTool], checkpointSaver: new MemorySaver(), });这里checkpointSaver是 LangGraph 用来保存每一步状态快照的它能在 Agent 循环内部保存运行状态调试的时候很关键。然后是记忆模块也就是上面的 ConversationMemory直接复用。入口文件把整个链路串起来// index.js import dotenv/config; import readline from node:readline/promises; import { HumanMessage, AIMessage } from langchain/core/messages; import { agent } from ./agent.js; import { ConversationMemory } from ./memory.js; const memory new ConversationMemory({ maxRecentMessages: 20, maxHistoryTokens: 3000, summaryModel: agent.llm, }); const rl readline.createInterface({ input: process.stdin, output: process.stdout, }); async function chat(userInput) { memory.add(new HumanMessage(userInput)); const context memory.getMessages(); const result await agent.invoke({ messages: context }); const newMessages result.messages.slice(context.length); for (const msg of newMessages) { memory.add(msg); } const reply newMessages.filter(m m instanceof AIMessage).at(-1); return reply ? reply.content : 无回复; } console.log(个人助理已启动输入 exit 退出); while (true) { const input await rl.question(\n你: ); if (input.toLowerCase() exit) break; const reply await chat(input); console.log(助理: ${reply}); } rl.close();5.3 运行效果与成本对比我实际跑了几个场景效果符合预期。第一轮让它记录一个事情明天上午10点开会Agent 调用工具后返回确认结果第二轮问我明天有什么事Agent 能基于之前的对话历史回答。第 15 轮对话后我特意问它最早提到的那个日程它依然记得——因为摘要里保留了。成本对比也很有参考价值。15 轮不压缩的对话最后几轮的输入 token 大概在 6000-8000 之间使用压缩后历史部分始终控制在 3000 token 以内加上当前输入单轮输入不超过 4000 token。长期运行后节省的成本很明显。6. 避坑实录五类高频问题排查这部分全是实际踩过的坑一个个看过去能帮你少走很多弯路。6.1 Node.js 版本与模块兼容开发中最常见的报错是The requested module node:util does not provide an export named parseEnv这类。原因是 Node 18 某些旧版本对 Node 内置模块的 ESM 导出支持不完整。解决方式要么升级 Node 到 20 LTS要么使用 CommonJS 导入方式require()。我建议直接升到 20别在这种问题上浪费生命。还有一种情况是npm install后启动就报错提示某个依赖包的版本不存在。大概率是 LangChain 生态最近的版本在快速迭代包之间的 peerDependencies 有冲突。解法用npm ls检查依赖树手动安装报错的依赖版本即可。6.2 LangChain API 版本漂移问题LangChain 的 API 变化很快网上很多教程代码已经过时。最典型的是initializeAgentExecutorWithOptions已经废弃官方推荐用 LangGraph 的createReactAgent替代。还有一个坑是老的AgentExecutor不支持新版本的部分消息类型。我的应对策略以官方文档的当前版本为准如果看第三方教程先确认发布时间。另外不要一股脑升级依赖大版本。如果你的项目跑得好好的就锁定版本号别手贱更新。6.3 Token 估算与实际调用误差js-tiktoken估算的是模型输入的 token 数但 LangChain 的某些类在底层可能会额外追加系统消息或格式化内容导致实际 token 比我估算的略高。另外工具定义的 schema 也会占用 token这部分在估算时容易漏掉。解决方式给记忆模块留 10%-20% 的余量。比如你的预算本来是 3000 token设置成 2500 就触发压缩留出 buffer。另外LangSmith 这类可观测工具可以精确查看每一次请求的 token 消耗线上项目建议接上。6.4 工具结果污染上下文工具返回的结果会作为消息进入上下文如果工具返回大量无用信息会严重污染模型判断。我踩过的坑是让天气工具返回整个 JSON 响应里面全是嵌套字段模型在后续对话中经常把这堆垃圾 JSON 当成交谈内容。解决办法有两个第一工具内部先做数据清洗只返回最终结论第二在工具描述里写清楚返回给用户的关键信息。例如天气工具只返回今天北京多云 18°C删掉所有中间字段。6.5 长对话记忆混乱即使有了压缩长对话里也可能出现张冠李戴的现象。比如用户先说了一个日程后来又改时间摘要合并时如果没处理好模型可能把两个版本都当作有效信息。我的应对方式是在摘要生成提示词里加上一条如果发现信息已经被后续对话修正只保留最新版本。同时涉及修改类操作的工具最好返回一个带时间戳的覆盖成功消息让模型知道旧值已经失效。这些细节看起来小但直接决定 Agent 在真实使用中的可靠度。上下文压缩不是写完一个类就完事需要结合自己的业务场景反复调参、打磨才算是真正落地了整套方案。