Nodejs也能写Agent - 22.LangGraph篇 - 上下文工程
上一篇我们把可观测性立起来了:streamEvents、LangSmith、结构化日志。出了错,你至少能看见「卡在哪一步」。
但说句扎心的:trace 再漂亮,也救不了窗口里塞的是垃圾。历史消息、RAG 片段、ToolMessage 一股脑堆进去——要么超限直接报错,要么噪声淹没关键句,模型一本正经地胡话。可观测性回答「发生了什么」;上下文工程(Context Engineering)回答「模型看到了什么」——这才直接决定它能不能推对。
这一篇把 Context Engineering 和 Prompt Engineering 掰开,讲清一次调用里上下文怎么组成、Token 怎么预算,以及 RAG 注入时怎么少塞垃圾。
老规矩,本文以官网最新文档核对过(Context engineering in agents、Short-term memory、Prebuilt middleware)。Agent 入口继续用
createAgent——别再抄createReactAgent。网上不少教程手写一个同名trimMessages——别这么干,官方就有trimMessages;摘要优先走summarizationMiddleware。
一、Prompt Engineering ≠ Context Engineering
PromptTemplate/ChatPromptTemplate——那是Prompt Engineering:把单条指令写清楚、格式对、few-shot 到位。
对话一变长、接上 RAG、再套多轮 tool 调用,上下文窗口就成了稀缺资源。这时你优化的不再是「这句话怎么措辞」,而是「这一整窗里放什么、什么顺序、超了怎么砍」——这就是 Context Engineering。
| 维度 | Prompt Engineering | Context Engineering |
|---|---|---|
| 关注点 | 单条 prompt 的措辞、格式、few-shot | 整段上下文的组成、顺序、长度与质量 |
| 范围 | 通常 system + 当前 user | system + 历史 + 检索片段 + 工具结果 + 元数据 |
| 目标 | 让模型「理解任务」 | 让模型「在有限窗口内看到最相关信息」 |
官网说得更狠一点:Agent 不可靠,往往不是模型不够聪明,而是没把「对的」上下文喂进去。AI Engineer 的头号工作,就是这件事。
二、一次调用里模型到底看到什么
先用落地直觉拆开——一次 Agent 调用的 context,通常长这样:
| 部分 | 来源 | 说明 |
|---|---|---|
| System Prompt | 固定或动态 | 角色、规则、工具使用约定 |
| 历史 Messages | Checkpointer / Memory | 多轮对话累积 |
| RAG 片段 | Retriever Top-K | 注入 prompt 的参考文档 |
| Tool 结果 | ToolMessage | ReAct 环里每次 tool 返回 |
| 当前 User Message | 用户输入 | 本轮问题 |
官网再给你一层更完整的坐标系——你能控的不只是「消息列表」,而是三类上下文:
| Context Type | 你在控什么 | Transient / Persistent |
|---|---|---|
| Model Context | 进模型的东西:instructions、message history、tools、用哪颗模型、response format | 多为Transient(只改本轮喂给模型的内容) |
| Tool Context | 工具能读什么、写什么(State / Store / Runtime Context) | Persistent |
| Life-cycle Context | 模型调用与工具调用之间发生什么(摘要、guardrails、日志……) | Persistent |
数据从哪来,也要分清:
| 数据源 | 范围 | 例子 |
|---|---|---|
| Runtime Context | 单次会话配置 | userId、权限、环境 |
| State(短期记忆) | 当前 thread | messages、tool 结果、上传文件 |
| Store(长期记忆) | 跨会话 | 用户偏好、沉淀事实 |
落地机制是 Middleware。createAgent的middleware让你在 agent loop 的钩子上改上下文——不必把裁剪逻辑糊进业务节点。
wrapModelCall:改本轮送给模型的 messages / tools / prompt——瞬时,默认不改 State。beforeModel/afterModel:可以返回 State 更新(例如删消息、换摘要)——持久,Checkpointer 下次还能看见。
搞不清 Transient vs Persistent,你就会踩这个坑:以为「裁过了」,结果 Checkpointer 里旧历史还在,下一轮又全塞回来。
三、Token 预算:三种控窗策略
模型窗口有限(本地小模型尤其狠)。管理原则很简单:给每块预算,超限有明确裁剪 / 压缩规则。
1. 保留最近 N 轮
只留最近几轮 user-assistant,更早的直接丢掉。实现最简单,适合短会话、demo。
别手写一个叫trimMessages的函数去抢官方名字——下面用官网 API。
2. 官方trimMessages:按 token / 边界裁剪
LangChain 提供trimMessages:按maxTokens、strategy: "last"、startOn/endOn裁消息列表,尽量保住对话结构(例如从 human 起、在 human/tool 结束,避免 AI↔Tool 成对被拦腰砍断)。
瞬时裁剪(只改本轮喂给模型的内容,State 原样保留)——用wrapModelCall:
import{createAgent,createMiddleware,trimMessages}from"langchain";import{ChatOllama}from"@langchain/ollama";import{tool}from"@langchain/core/tools";import*aszfrom"zod";constgetWeather=tool(async({city}:{city:string})=>`${city}:晴,25°C`,{name:"get_weather",description:"查询城市天气",schema:z.object({city:z.string()}),});/** 教学用:按「条数」近似计数;生产请换成真实 tokenCounter(或模型自带计数) */constroughCounter=(msgs:{length:number}|unknown[])=>Array.isArray(msgs)?msgs.length:0;consttransientTrim=createMiddleware({name:"TransientTrim",wrapModelCall:async(request,handler)=>{consttrimmed=awaittrimMessages(request.messages,{maxTokens:12,// 演示阈值;生产按模型窗口设strategy:"last",startOn:"human",endOn:["human","tool"],includeSystem:true,// 保住开头的 systemtokenCounter:roughCounter,});// override:只改本轮请求,不写回 Statereturnhandler(request.override({messages:trimmed}));},});constllm=newChatOllama({model:"qwen2.5:7b",temperature:0});constagent=createAgent({model:llm,tools:[getWeather],systemPrompt:"需要天气时调用 get_weather。回答简洁。",middleware:[transientTrim],});持久裁剪(真把 State 里的旧消息清掉,配合 Checkpointer 才有意义)——beforeModel+RemoveMessage:
import{RemoveMessage}from"@langchain/core/messages";import{createAgent,createMiddleware,trimMessages}from"langchain";import{MemorySaver,REMOVE_ALL_MESSAGES}from"@langchain/langgraph";import{ChatOllama}from"@langchain/ollama";constpersistTrim=createMiddleware({name:"PersistTrim",beforeModel:async(state)=>{consttrimmed=awaittrimMessages(state.messages,{maxTokens:20,strategy:"last",startOn:"human",endOn:["human","tool"],includeSystem:true,tokenCounter:(msgs)=>msgs.length,// 演示用;生产换真实计数});// 先清空再写入裁剪结果 → State 永久变短return{messages:[newRemoveMessage({id:REMOVE_ALL_MESSAGES}),...trimmed],};},});constagent=createAgent({model:newChatOllama({model:"qwen2.5:7b",temperature:0}),tools:[],middleware:[persistTrim],checkpointer:newMemorySaver(),});// 同一 thread_id 多轮 invoke:裁剪结果会跟着存档走awaitagent.invoke({messages:[{role:"user",content:"我叫小明"}]},{configurable:{thread_id:"u-1"}});| 策略 | 改 State? | 适合 |
|---|---|---|
wrapModelCall+trimMessages | 否(Transient) | 调试、按调用临时瘦身、还想保留完整审计历史 |
beforeModel+RemoveMessage | 是(Persistent) | Checkpointer 长会话,必须真的减负 |
summarizationMiddleware | 是(Persistent) | 长对话要「记得大概」,不能硬砍细节 |
3. 摘要压缩:summarizationMiddleware
硬 trim 会丢信息。长会话更常见的做法:旧消息用另一颗(可更小更便宜的)模型压成摘要,永久写回 State,只保留最近若干条原文。
import{createAgent,summarizationMiddleware}from"langchain";import{ChatOllama}from"@langchain/ollama";import{MemorySaver}from"@langchain/langgraph";constchatModel=newChatOllama({model:"qwen2.5:7b",temperature:0});// 摘要可以用同一模型,生产常换成更小/更便宜的constsummaryModel=newChatOllama({model:"qwen2.5:7b",temperature:0});constagent=createAgent({model:chatModel,tools:[],checkpointer:newMemorySaver(),middleware:[summarizationMiddleware({model:summaryModel,trigger:{tokens:4000},// 越过阈值才摘要keep:{messages:20},// 保留最近 20 条原文}),],});触发条件还可写成「多条件 AND」或「数组 OR」(见官网 Prebuilt middleware)。注意:摘要是文本向压缩——多模态大图不会被「压小」,只会被摘要文字替代;图多的场景要把媒体放对象存储,消息里只留 URL。
四、RAG 怎么注入才不搅浑
这里只盯一件事:检索到的文档怎么塞进 prompt——格式不对,模型分不清「资料」和「问题」,引用也乱。
分隔符 + 引用格式
--- 检索到的参考文档 --- [来源: doc-a.md] …… --- 参考文档结束 --- 用户问题:……并明确要求:答不出就说不知道;引用时标[来源: xxx]。
import{Document}from"@langchain/core/documents";import{ChatPromptTemplate}from"@langchain/core/prompts";import{ChatOllama}from"@langchain/ollama";import{StringOutputParser}from"@langchain/core/output_parsers";import{RunnableSequence}from"@langchain/core/runnables";/** 把检索文档格式化成带来源的 context */functionformatRagContext(docs:Document[]):string{returndocs.map((d,i)=>`[来源:${d.metadata.source??`doc-${i}`}]\n${d.pageContent}`).join("\n\n---\n\n");}constprompt=ChatPromptTemplate.fromTemplate(`根据以下参考文档回答问题。若无法从文档得出答案,请说「我不知道」。 回答时请用 [来源: xxx] 标注引用。 --- 检索到的参考文档 --- {context} --- 参考文档结束 --- 用户问题:{question}`);constllm=newChatOllama({model:"qwen2.5:7b",temperature:0});constragChain=RunnableSequence.from([async(input:{question:string;docs:Document[]})=>({context:formatRagContext(input.docs),question:input.question,}),prompt,llm,newStringOutputParser(),]);// ragChain.invoke({ question: "...", docs: retrievedDocs })Top-K 与 chunk 也是预算
| 旋钮 | 太大 | 建议起步 |
|---|---|---|
k | 噪声淹没相关句 | 3~5 |
chunkSize | 单条占满窗口 | 视文档类型:FAQ 可 200~300,论述可更大 |
多轮 + RAG + tool 结果三者叠加时,先给历史 / RAG / tools 各自定预算,再决定 trim 还是摘要——别等 API 报context length exceeded再救火。
工具结果特别脏、特别长时,还可以看官网的contextEditingMiddleware(如ClearToolUsesEdit):专门清旧 tool 调用块,避免 ToolMessage 永久占地。本篇不展开,知道有这号预置中间件即可。
五、反模式速查表
| 反模式 | 后果 | 缓解 |
|---|---|---|
| 塞满 context | 「迷失」在无关信息里,忽略关键句 | 预算 + trim / 摘要 |
| 检索噪声淹没相关信息 | 基于错误资料一本正经胡答 | 降k、重排、分隔符、强制「无则不知」 |
| 历史从不裁剪 | 超限报错或被截断 | trimMessages/summarizationMiddleware |
| Tool 结果永不清理 | ToolMessage 挤占 user 问题空间 | 只留近几轮 tool;或 context editing |
| system prompt 过长 | 规则/工具说明占满窗口 | 精简 system;动态工具子集(官网 Tool Context) |
| 把瞬时 trim 当永久清理 | Checkpointer 下轮又全量塞回 | 长会话用 Persistent 策略 |
常见坑
- 只改 Prompt 不管理 context:长对话 + RAG 后必然爆窗。
- RAG 无分隔直接拼接:模型分不清文档与问题。
- trim 丢掉 system:角色与规则蒸发——设
includeSystem: true,或保证 system 始终在保留集里。 - 裁断 AI↔Tool 成对消息:部分供应商会直接拒收非法历史——用
startOn/endOn保结构。 k过大:10+ chunk 塞满窗口,噪声赢相关。- 摘要当真理:细节会丢;关键事实该进 Store / 结构化记忆,别全靠一段 summary。
- 自写同名
trimMessages:和官方 API 撞车,后人难维护——用官网的。
