Nodejs也能写Agent - 22.LangGraph篇 - 上下文工程

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 EngineeringPromptTemplate/ChatPromptTemplate——那是Prompt Engineering把单条指令写清楚、格式对、few-shot 到位。对话一变长、接上 RAG、再套多轮 tool 调用上下文窗口就成了稀缺资源。这时你优化的不再是「这句话怎么措辞」而是「这一整窗里放什么、什么顺序、超了怎么砍」——这就是 Context Engineering。维度Prompt EngineeringContext Engineering关注点单条 prompt 的措辞、格式、few-shot整段上下文的组成、顺序、长度与质量范围通常 system 当前 usersystem 历史 检索片段 工具结果 元数据目标让模型「理解任务」让模型「在有限窗口内看到最相关信息」官网说得更狠一点Agent 不可靠往往不是模型不够聪明而是没把「对的」上下文喂进去。AI Engineer 的头号工作就是这件事。二、一次调用里模型到底看到什么先用落地直觉拆开——一次 Agent 调用的 context通常长这样Context 组成System Prompt历史 MessagesRAG 检索片段Tool 结果 ToolMessage当前 User Message部分来源说明System Prompt固定或动态角色、规则、工具使用约定历史 MessagesCheckpointer / Memory多轮对话累积RAG 片段Retriever Top-K注入 prompt 的参考文档Tool 结果ToolMessageReAct 环里每次 tool 返回当前 User Message用户输入本轮问题官网再给你一层更完整的坐标系——你能控的不只是「消息列表」而是三类上下文Context Type你在控什么Transient / PersistentModel Context进模型的东西instructions、message history、tools、用哪颗模型、response format多为Transient只改本轮喂给模型的内容Tool Context工具能读什么、写什么State / Store / Runtime ContextPersistentLife-cycle Context模型调用与工具调用之间发生什么摘要、guardrails、日志……Persistent数据从哪来也要分清数据源范围例子Runtime Context单次会话配置userId、权限、环境State短期记忆当前 threadmessages、tool 结果、上传文件Store长期记忆跨会话用户偏好、沉淀事实落地机制是 Middleware。createAgent的middleware让你在 agent loop 的钩子上改上下文——不必把裁剪逻辑糊进业务节点。tool_callsdone__start__beforeModelModel CallafterModelTools__end__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 原样保留——用wrapModelCallimport{createAgent,createMiddleware,trimMessages}fromlangchain;import{ChatOllama}fromlangchain/ollama;import{tool}fromlangchain/core/tools;import*aszfromzod;constgetWeathertool(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;consttransientTrimcreateMiddleware({name:TransientTrim,wrapModelCall:async(request,handler){consttrimmedawaittrimMessages(request.messages,{maxTokens:12,// 演示阈值生产按模型窗口设strategy:last,startOn:human,endOn:[human,tool],includeSystem:true,// 保住开头的 systemtokenCounter:roughCounter,});// override只改本轮请求不写回 Statereturnhandler(request.override({messages:trimmed}));},});constllmnewChatOllama({model:qwen2.5:7b,temperature:0});constagentcreateAgent({model:llm,tools:[getWeather],systemPrompt:需要天气时调用 get_weather。回答简洁。,middleware:[transientTrim],});持久裁剪真把 State 里的旧消息清掉配合 Checkpointer 才有意义——beforeModelRemoveMessageimport{RemoveMessage}fromlangchain/core/messages;import{createAgent,createMiddleware,trimMessages}fromlangchain;import{MemorySaver,REMOVE_ALL_MESSAGES}fromlangchain/langgraph;import{ChatOllama}fromlangchain/ollama;constpersistTrimcreateMiddleware({name:PersistTrim,beforeModel:async(state){consttrimmedawaittrimMessages(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],};},});constagentcreateAgent({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适合wrapModelCalltrimMessages否Transient调试、按调用临时瘦身、还想保留完整审计历史beforeModelRemoveMessage是PersistentCheckpointer 长会话必须真的减负summarizationMiddleware是Persistent长对话要「记得大概」不能硬砍细节3. 摘要压缩summarizationMiddleware硬 trim 会丢信息。长会话更常见的做法旧消息用另一颗可更小更便宜的模型压成摘要永久写回 State只保留最近若干条原文。import{createAgent,summarizationMiddleware}fromlangchain;import{ChatOllama}fromlangchain/ollama;import{MemorySaver}fromlangchain/langgraph;constchatModelnewChatOllama({model:qwen2.5:7b,temperature:0});// 摘要可以用同一模型生产常换成更小/更便宜的constsummaryModelnewChatOllama({model:qwen2.5:7b,temperature:0});constagentcreateAgent({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}fromlangchain/core/documents;import{ChatPromptTemplate}fromlangchain/core/prompts;import{ChatOllama}fromlangchain/ollama;import{StringOutputParser}fromlangchain/core/output_parsers;import{RunnableSequence}fromlangchain/core/runnables;/** 把检索文档格式化成带来源的 context */functionformatRagContext(docs:Document[]):string{returndocs.map((d,i)[来源:${d.metadata.source??doc-${i}}]\n${d.pageContent}).join(\n\n---\n\n);}constpromptChatPromptTemplate.fromTemplate(根据以下参考文档回答问题。若无法从文档得出答案请说「我不知道」。 回答时请用 [来源: xxx] 标注引用。 --- 检索到的参考文档 --- {context} --- 参考文档结束 --- 用户问题{question});constllmnewChatOllama({model:qwen2.5:7b,temperature:0});constragChainRunnableSequence.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噪声淹没相关句35chunkSize单条占满窗口视文档类型FAQ 可 200300论述可更大多轮 RAG tool 结果三者叠加时先给历史 / RAG / tools 各自定预算再决定 trim 还是摘要——别等 API 报context length exceeded再救火。工具结果特别脏、特别长时还可以看官网的contextEditingMiddleware如ClearToolUsesEdit专门清旧 tool 调用块避免 ToolMessage 永久占地。本篇不展开知道有这号预置中间件即可。五、反模式速查表反模式后果缓解塞满 context「迷失」在无关信息里忽略关键句预算 trim / 摘要检索噪声淹没相关信息基于错误资料一本正经胡答降k、重排、分隔符、强制「无则不知」历史从不裁剪超限报错或被截断trimMessages/summarizationMiddlewareTool 结果永不清理ToolMessage 挤占 user 问题空间只留近几轮 tool或 context editingsystem 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 撞车后人难维护——用官网的。