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

📅 2026/7/22 14:24:08 👁️ 阅读次数 📝 编程学习
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 EngineeringContext Engineering
关注点单条 prompt 的措辞、格式、few-shot整段上下文的组成、顺序、长度与质量
范围通常 system + 当前 usersystem + 历史 + 检索片段 + 工具结果 + 元数据
目标让模型「理解任务」让模型「在有限窗口内看到最相关信息」

官网说得更狠一点:Agent 不可靠,往往不是模型不够聪明,而是没把「对的」上下文喂进去。AI Engineer 的头号工作,就是这件事。

二、一次调用里模型到底看到什么

先用落地直觉拆开——一次 Agent 调用的 context,通常长这样:

Context 组成

System Prompt

历史 Messages

RAG 检索片段

Tool 结果 ToolMessage

当前 User Message

部分来源说明
System Prompt固定或动态角色、规则、工具使用约定
历史 MessagesCheckpointer / Memory多轮对话累积
RAG 片段Retriever Top-K注入 prompt 的参考文档
Tool 结果ToolMessageReAct 环里每次 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(短期记忆)当前 threadmessages、tool 结果、上传文件
Store(长期记忆)跨会话用户偏好、沉淀事实

落地机制是 Middleware。createAgentmiddleware让你在 agent loop 的钩子上改上下文——不必把裁剪逻辑糊进业务节点。

tool_calls

done

__start__

beforeModel

Model Call

afterModel

Tools

__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:按maxTokensstrategy: "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 策略

常见坑

  1. 只改 Prompt 不管理 context:长对话 + RAG 后必然爆窗。
  2. RAG 无分隔直接拼接:模型分不清文档与问题。
  3. trim 丢掉 system:角色与规则蒸发——设includeSystem: true,或保证 system 始终在保留集里。
  4. 裁断 AI↔Tool 成对消息:部分供应商会直接拒收非法历史——用startOn/endOn保结构。
  5. k过大:10+ chunk 塞满窗口,噪声赢相关。
  6. 摘要当真理:细节会丢;关键事实该进 Store / 结构化记忆,别全靠一段 summary。
  7. 自写同名trimMessages:和官方 API 撞车,后人难维护——用官网的。