首页 / 资讯中心 / 文章详情

AI Agent上下文工程实战:解决模型失忆与答非所问的关键技术

AI Agent上下文工程实战:解决模型失忆与答非所问的关键技术 ★ FEATURED ARTICLE
从写代码到现在我做过不少AI Agent项目也踩过不少坑。早期总觉得Agent不聪明是模型选得不对后来把GPT-4换成开源模型该不聪明还是不聪明直到真正开始系统性地思考上下文工程这件事才发现大部分问题都出在这——模型能力的天花板远比我们想象得高真正拉开体验差距的是喂给模型的信息到底是什么、怎么组织、怎么管理。这条经验对于正在做Agent应用、被各种失忆和答非所问折磨的朋友尤其有用。上下文工程不是学术概念它是实实在在的工程能力。这篇文章我会把我实践过程中积累的东西完整拆开讲从原理到代码从策略选择到坑点排查尽量说人话。1. 上下文工程到底在解决什么1.1 从选模型到管上下文的思路转变很多团队启动Agent项目时第一件事是选模型第二件事是调Prompt第三件事才是做应用逻辑。但做了一段时间就会发现Prompt调得再精细模型一旦面对多轮对话、工具调用、临时知识的混合场景还是会犯低级错误。举个我实际遇到的例子一个客服型Agent用户在第一轮说我家冰箱型号是BCD-123制冷有问题然后聊了半小时其他问题中途模型调用工具查询了保修政策。等到用户最后问我这个型号能免费维修吗时模型因为中间塞满了工具日志和无关对话完全忘了冰箱型号这件事反过来问用户请问您的冰箱是什么型号。这不是模型笨是窗口里的信息太乱了。上下文工程的核心目标就是保证模型在有限的窗口内始终拥有它当前这一步最需要的、最精确的信息。它拷问的不是模型能力而是我们做工程的人有没有把信息流管理到位。1.2 Agent运行时上下文里到底装了什么东西要把上下文工程讲透得先知道Agent的一次响应中窗口里堆了哪些内容。拆开看其实就五类系统提示词角色设定、行为规范、输出格式要求这是固定开销。历史对话记录用户和助手此前的所有交互文本可能还包括中间的情绪语气、闲聊。工具调用结果函数返回的JSON、数据库查询结果、API响应日志这些往往又长又杂。外部知识注入通过RAG检索到的文档片段、企业知识库条目。当前用户输入本次对话的原始请求。这五类内容全挤在同一个上下文窗口里而窗口大小是硬约束即便现在上下文窗口动辄128K甚至200K但塞满后依然有性能衰减、注意力分散的问题。上下文工程做的就是决定每一类内容放多少、按什么顺序放、什么时候更新、什么时候淘汰。1.3 为什么说窗口越大越需要工程有人会抬杠现在模型不是支持200K上下文了吗直接全塞进去不就行了我试过真不行。第一成本受不了。Token是按量计费的用户闲聊十分钟可能就积累上万Token全塞进窗口意味着每次请求都要重新处理这些历史Token成本随对话轮次线性增长生产环境根本扛不住。第二模型注意力会被稀释。有一项很直观的体验当你把大量无关历史塞给模型时它对关键信息的敏感度会下降。就好比你让一个人在堆满杂物的房间里找一把钥匙房间越大他越容易忽略该看的地方。第三延迟增加。每多一个Token模型首字响应时间都会变长用户面对一个转圈十秒才回话的Agent体验不会好。所以上下文工程的正解不是塞得下就塞而是用最少的Token、最合理的结构换来最精准的任务完成度。这本质上是信息架构的能力。2. 上下文管理的四类核心技术路线2.1 滑动窗口截断简单直接但不能乱用最容易上手的方式就是截断——只保留最近N轮对话其余直接丢。很多初版Agent都是这么干的实现起来也确实简单。不过直接按轮次截断有两个问题。第一对话轮次的长度差异很大有人一轮就二十句话有人一轮只说三个字按轮次截断对Token的控制不够精准。第二盲目丢弃历史可能会丢掉关键约定信息比如开头设定的用户姓名、讨论过的目标、确认过的约束条件。改进的做法是分角色截断用户输入必须全保留因为那是最新的意图助手回复可以适量丢工具调用的日志最佳做法是不进历史文本只把最终结果的结构化摘要存下来。我在生产环境里的滑窗参数一般是这样的方案保留内容触发条件轮次级截断最近6轮完整对话 前面所有轮的摘要超过6轮时触发Token级截断最近4000 Token对话 前置摘要超过预算时触发混合策略用户输入100%保留助手回复压缩至摘要每轮都做增量更新2.2 摘要压缩用信息密度换空间摘要压缩的核心思路很简单与其把原始对话全部留着不如把旧对话提炼成几句摘要释放空间给新内容。摘要分两类。固定摘要的意思是每积攒N轮就统一总结一次递进摘要更精细每轮对话结束后把现有摘要 新对话压缩成一份新摘要。实际做的时候有个重要经验摘要不能只让模型自由发挥必须给模型强制要求让它必须保留以下几类信息用户明确给出的个人偏好或身份属性对话涉及的核心实体和状态订单号、设备型号、已确认的方案尚未完成的任务和承诺任何用户表达过的情绪或态度变化否则模型会在摘要里只写用户咨询了冰箱维修问题但把冰箱型号BCD-123这个关键实体弄丢了。给摘要加上信息保全面板效果会完全不一样。另一个容易被忽略的点摘要本身也要带元数据。我习惯在每条摘要前面加一个时间戳和区间信息例如[2025-01-10 10:30-10:45, 4轮对话摘要]这样后续做检索时能够判断摘要的时效性。2.3 向量检索给Agent造一个外部背景板截断和摘要处理的是旧对话但有一种情况它们都搞不定——用户在很久之前说的一句话恰恰和当前问题相关。比如用户第一天说我在杭州想找附近的宠物店三天后问上次说的那家店怎么样。这种跨越长周期的语义关联摘要压缩大概率已经把细节丢了截断更不用谈。向量检索的思路是把所有历史消息切成片段向量化后存入向量数据库每次用户提问时先对用户输入做向量化再从历史向量库中召回到Top K条最相关的片段拼进上下文。这个方案做个人助理类Agent非常合适。它不是把所有历史都塞给模型而是把历史变成一块可检索的背景板模型需要的时候才去翻。我在项目里的典型参数是向量模型BAAI/bge-m3中文效果好兼容英文切分粒度按对话轮次切不按固定字符切Top K取35条相似度阈值低于0.5不注入需要注意的是向量检索召回的不一定是完整信息有时Top 3里只有一段提到型号、另外一段提到了地址需要再做一次重排或拼接。否则模型看到的信息还是碎片化的。2.4 结构化记忆把文本流变成状态表这是目前我在生产环境里最推荐的方式适合业务相对固定的Agent场景客服、售后、工单、营销等。核心思想是不要试图把上下文做成一段自然的文本流直接把关键信息抽出来变成结构化的状态字段和变量。比如一个售后Agent它的记忆不应该只是对话文字而是一个由对话不断更新维护的状态表{ user_profile: { name: 张三, member_level: VIP, contact: 138xxxx }, issue_info: { category: refund, order_id: SO-202501-001, device_model: BCD-123, refund_status: pending_approval }, conversation_milestones: [ 用户对物流时效不满, 客服已解释退款政策, 用户要求加急处理 ], next_action: escalate_to_manager }这样的结构给Agent带来的改变是本质性的。模型不再是阅读一整段混杂文本然后自己猜状态而是直接读到清晰的当前状态。状态可以被业务逻辑直接读取和校验可以预置默认值可以多轮增量更新。而且多个Agent之间需要共享状态时传这个结构化记忆比传一整段对话摘要可靠太多。Token开销上这种结构最省——一份完整的用户画像加任务状态可能只有300个Token而不是三千字对话记录。信息密度高出几个数量级。3. 实操FastAPI LangChain LangGraph 实现一套上下文管理系统3.1 工程栈选型的逻辑先说我为什么选这套栈。FastAPI负责提供API网关和异步处理能力Agent并发上来时FastAPI的异步特性是刚需LangChain提供现成的工具调用封装、Prompt模板管理和各类模型适配省去很多胶水代码LangGraph则适合构建有状态的工作流尤其是Agent需要多步推理、条件跳转的场景它的StateGraph可以天然对接结构化记忆。这套搭配也是目前社区里做Agent中台和复杂工作流的主流组合招聘要求里都在写。选它不是因为它新是因为它确实能扛住生产环境的复杂度。3.2 定义上下文管理的核心数据结构动手写代码前我会先定义好上下文的透明结构。这一步别省后面所有功能都要建立在清晰的数据结构上。我一般把上下文分成三类字段固定信息系统配置、可更新状态任务进度、动态历史对话摘记。from dataclasses import dataclass, field from typing import Any, Optional dataclass class TurnRecord: 单轮对话记录 role: str # user / assistant / tool content: str # 文本内容或工具结果摘要 timestamp: float 0.0 # 时间戳用于排序和淘汰 token_count: int 0 # 预计算的Token数方便预算控制 dataclass class AgentContext: Agent会话上下文 session_id: str system_prompt: str state: dict[str, Any] field(default_factorydict) # 结构化记忆 history: list[TurnRecord] field(default_factorylist) # 滑窗保留的近期对话 summary: str # 早期对话的摘要 external_knowledge: list[str] field(default_factorylist) # RAG注入知识这个结构把结构化记忆和自然语言历史分开。state直接用于业务决策summary用于全局概览history则承载最近的细节上下文。external_knowledge最后注入确保它是模型最近看到的信息。3.3 上下文管理器负责预算、注入和更新真正的核心是一个ContextManager类它负责三件事一是在模型请求前组装Prompt二是请求结束后更新记忆三是实时监控Token预算。class ContextManager: def __init__(self, max_tokens: int 8000): self.max_tokens max_tokens self._tokenizer None # 实际项目中用tiktoken或其他tokenizer def _estimate_tokens(self, text: str) - int: 估算Token数生产环境建议用正式tokenizer # 中文场景下一个字符约等于0.6~1个token这里用粗略比例 return int(len(text) * 0.75) def assemble_prompt(self, ctx: AgentContext, user_input: str) - str: 按优先级组装最终上下文 parts [] # 1. 系统提示词——固定开销控制在800 token内 parts.append(f[SYSTEM]\n{ctx.system_prompt}) # 2. 结构化记忆——转换成紧凑文本 if ctx.state: import json parts.append(f[CURRENT STATE]\n{json.dumps(ctx.state, ensure_asciiFalse)}) # 3. 历史摘要——已压缩的远期信息 if ctx.summary: parts.append(f[EARLY SUMMARY]\n{ctx.summary}) # 4. 滑窗历史——只保留近几轮 window_text self._format_history(ctx.history) if window_text: parts.append(f[RECENT HISTORY]\n{window_text}) # 5. 外部知识——RAG检索结果 if ctx.external_knowledge: k_text \n.join(f- {k} for k in ctx.external_knowledge) parts.append(f[RETRIEVED KNOWLEDGE]\n{k_text}) # 6. 当前输入 parts.append(f[USER INPUT]\n{user_input}) assembled \n\n.join(parts) # 若超出预算先压缩历史再裁剪外部知识 while self._estimate_tokens(assembled) self.max_tokens: if ctx.external_knowledge: ctx.external_knowledge.pop(0) elif len(ctx.history) 2: ctx.history.pop(0) else: break assembled \n\n.join(parts) return assembled def update_after_turn(self, ctx: AgentContext, new_turn: TurnRecord, assistant_turn: TurnRecord): 一轮结束后更新历史和状态 ctx.history.append(new_turn) ctx.history.append(assistant_turn) # 维持滑窗长度 if len(ctx.history) 10: # 把最早的两轮合并进摘要 oldest ctx.history[:2] new_summary self._summarize( old_summaryctx.summary, new_messagesself._format_history(oldest) ) ctx.summary new_summary del ctx.history[:2] def _summarize(self, old_summary: str, new_messages: str) - str: 调用LLM进行摘要强制保留关键信息 prompt f你是对话摘要引擎。保留以下信息 1. 用户身份与偏好 2. 关键实体与状态订单号、型号、金额 3. 未完成的任务与承诺 4. 用户情绪变化 已有摘要 {old_summary} 新增对话 {new_messages} 请输出更新后的摘要200字内 # 实际项目中调用LLM这里省略 return 更新后的摘要代码里有两个细节值得注意。第一个是组装顺序。系统提示词在最前面结构化状态紧随其后然后依次是早期摘要、近期历史、外部知识和用户输入。之所以把用户输入放最后是因为很多模型对窗口末尾的内容注意力更强用户最新的问题必须留在最后避免被淹没。第二个是超预算时的裁剪顺序。我优先丢弃外部知识其次丢早期历史最后才丢近期对话。因为外部知识是锦上添花的参考而近期对话承载了任务连续性丢了会导致模型忽略刚才的讨论。3.4 用LangGraph搭建带记忆的工作流数据结构和上下文管理器准备好了要让它跑起来还需接入工作流。LangGraph的好处在于它可以显式地把读取上下文和更新上下文变成工作流中的节点而不是散落在业务逻辑各处的零散调用。from langgraph.graph import StateGraph, START, END from typing import TypedDict, Annotated class WorkflowState(TypedDict): context: AgentContext user_input: str response: str def recall_node(state: WorkflowState): 记忆召回节点从存储中恢复结构化记忆并执行RAG检索 ctx state[context] # 加载结构化记忆从Redis/DB中按session_id读取 ctx.state load_state(ctx.session_id) # RAG根据用户输入召回相关知识 ctx.external_knowledge retrieve_knowledge(state[user_input]) return {context: ctx} def think_node(state: WorkflowState): 推理决策节点组装上下文调用LLM并决定是否调用工具 ctx state[context] prompt context_manager.assemble_prompt(ctx, state[user_input]) # 省略LLM调用和工具调用分支 return {response: agent_response} def memory_node(state: WorkflowState): 记忆固化节点更新结构化状态和滑动窗口历史 ctx state[context] # 调用LLM抽取最新状态更新state extract_and_update_state(ctx) # 更新历史窗口和摘要 context_manager.update_after_turn(ctx, ...) save_state(ctx.session_id, ctx.state) return {context: ctx} graph StateGraph(WorkflowState) graph.add_node(recall, recall_node) graph.add_node(think, think_node) graph.add_node(memory, memory_node) graph.add_edge(START, recall) graph.add_edge(recall, think) graph.add_edge(think, memory) graph.add_edge(memory, END)这个工作流的好处是结构清晰recall负责取记忆think负责推理干活memory负责固化新记忆。每个节点职责单一便于测试和排障。实际项目中think节点里会有工具调用的循环可能还要加action_node来处理函数调用但上下文的增删改都集中在recall和memory两个节点里。3.5 参数配置的实战参考值不同业务场景对上下文策略的参数要求差异很大但有几个默认值可以参考参数推荐值适用场景系统提示词Token预算800~1200全部场景越短越好滑窗保留轮数6~10轮客服、营销类Agent摘要触发阈值窗口40%-50%需要控制Token的强预算项目向量检索Top K3~5条RAG历史混合场景结构化状态Token占比5%-10%业务规则复杂的Agent一个经验是如果Agent需要处理的多是流程性任务比如工单、审批、售后优先做结构化状态如果Agent需要开放式聊天比如陪伴、写作助手那滑窗加摘要更合适如果用户依赖长周期记忆比如个人助理那必须引入向量检索。4. 常见问题与排查技巧4.1 上下文泄漏用户数据串场了这是最严重的问题发生场景通常是把全局上下文对象错误复用到了多用户会话中。常见根因有两个一是FastAPI里用了模块级的全局变量存context二是LangGraph的工作流状态中用户session_id没传对导致不同用户共享了同一个AgentContext。排查方法很简单在recall_node加一行日志把session_id和state一并打出来。一旦发现两个请求的session_id相同基本就是状态存储的key设计出了问题。我的建议是session_id直接用UUID不用用户名或手机号每个请求创建一个新的AgentContext实例杜绝复用。4.2 历史摘要越滚越失真摘要压缩有一个致命问题它会累积丢失错误。第一次摘要丢掉的信息第二次做增量摘要时根本不知道它存在过于是永远找不回来。如果早期摘要里把用户要求退款写成了用户询问退款政策后续所有决策都会被带偏。我在实践中要求摘要模型输出一个不确定性标记——如果某些信息不太确定就标注[uncertain]后续对话中一旦有矛盾信息优先按新信息修正。另外每周或者每个大任务结束后应该做一次全量原始对话的重新摘要修正增量摘要的偏差。4.3 Token预算统计不准很多项目踩过这个坑用len(text)除以4来估算英文Token其实误差不大但中文一个字符约等于0.6到1个Token不同模型的tokenizer差异非常大。我见过估算偏小导致请求直接超限报错的情况也见过估算偏大导致窗口利用率极低的。生产环境务必用正式tokenizer做预算统计不要用粗略估算。至少要保证系统提示词、历史、摘要、外部知识、用户输入能分开统计记录每部分实际消耗方便观察哪一块在持续膨胀。4.4 让上下文变得可观测上下文工程最痛苦的是排查模型为什么这么说。因为它本质上是一个黑盒我们看不到模型实际看到的窗口内容。我的做法是为每个请求生成一个context_debug字段把最终组装出的完整Prompt写入日志文件并按session_id归档。排查时直接翻日志看模型当时到底收到了什么。这个习惯救过我很多次。有一次用户投诉Agent泄露了其他用户信息我打开日志一看发现是工具调用接口返回的JSON里包含了一个多余的customer_name字段而工具日志直接进入了历史窗口。如果不做可观测化这种bug几乎不可能定位。建议在开发环境和测试环境都保留完整的Prompt日志生产环境则根据合规要求降级为只保留结构化元数据比如各部分Token占比、外部知识条数、摘要长度等。5. 后续扩展方向上下文工程没有一劳永逸的银弹但随着项目复杂度上升有几个方案可以按需引入。一个是多级记忆分层工作记忆只放当前几步所需的对话状态短期记忆放最近会话的关键事实长期记忆存入向量库。这有点像人的大脑——你需要的是快速记下当前正在做的事而不是把所有历历往事都摆在桌面上。另一个是主动式记忆管理现在的Agent都是在每轮对话后被动更新记忆可以升级为根据任务阶段主动预加载相关信息。比如用户说到我要退货Agent在工具调用前就先把订单信息和退换货政策从知识库捞出来而不是等用户问到时再现查。还有一个方向是跨会话记忆共享同一个用户在不同的Agent客服、营销、售后之间穿梭时通过结构化记忆中枢共享用户画像和任务状态。这在中台化的Agent架构里价值尤其大。我个人目前最看好的还是结构化记忆为核心的混合架构——它既保留了业务逻辑的可控性又能通过Retrieval补足长尾语义信息。做Agent开发的同行可以沿着这个方向深入试试这套方法论在大多数场景下都不会过时。
阅读完成 · 觉得有帮助?
咨询建站