做生成式AI应用的时间越长,越觉得这东西像搭积木。基础模型是积木本身,但怎么搭、连接处怎么处理、塌了怎么补救,才是决定项目能不能落地的关键。前面两篇我们聊了提示模板的基础模式和RAG检索增强模式,这一篇继续往下走,专门聊Agent场景下的工具调用和上下文管理。如果你正在做智能体、自动化工作流、或复杂对话系统,这部分内容大概率能帮你少走几个月的弯路。
我自己的体会是,很多团队Demo做得飞快,一到生产环境就崩,问题基本都出在工具调用没有章法、上下文管理一团乱麻。生成式AI设计模式,本质就是把那些反复踩坑之后沉淀下来的套路,变成一套可以照抄的作业。这篇不会讲太多空泛的概念,重点放在可落地的实现细节和踩坑记录上。
1. 生成式AI设计模式体系回顾与第三篇定位
1.1 为什么生成式AI也需要设计模式
传统软件工程里的设计模式,解决的是对象之间怎么协作、怎么解耦、怎么复用。生成式AI应用的开发,表面上是在写Prompt、调API,但实际面对的问题同样是协作和复用:多个工具怎么被模型调度?对话历史怎么管理?记忆怎么分层?输出怎么约束?这些问题如果每次从零开始想,项目必然失控。
设计模式的价值在于它提供了一套已经验证过的解决套路。比如你遇到上下文太长,第一反应可能是暴力截断,但成熟的做法是滑动窗口加摘要压缩。这不是什么高深理论,是很多团队用真实流量喂出来的经验。生成式AI应用迭代速度太快,更需要这种模式化的沉淀,否则每次需求一变就得重构一大片。
另外,设计模式也是一套通用的语言。你说“这里用RAG模式”,对方马上明白你要做检索增强;你说“工具调用走语义路由”,对方知道模型不是写死调用哪个API。这种沟通成本上的节省,在跨团队协作时特别明显。
1.2 前两篇覆盖的内容与第三篇重点
这个系列我们按场景层层递进。第一篇聊了提示工程的基础模式,比如模板固化、少样本示例、输出格式约束,解决的是“怎么让模型稳定输出”的问题。第二篇聊了RAG检索增强模式,从文档切分、向量索引、召回排序,到怎么把检索结果安全地塞进上下文,解决的是“怎么让模型拥有外部知识”的问题。
第三篇聚焦Agent场景。当模型不只是生成文字,而是需要调用外部工具、读取文件、查询数据库、执行动作的时候,整个系统的复杂度会再上一个台阶。这篇文章要拆解三个核心模式:
- 工具调用模式:模型如何理解并调度真实函数
- 上下文管理模式:多轮对话中如何保住关键信息
- 记忆分层模式:短期记忆和长期记忆如何协同
这三个点刚好是Agent开发中最容易翻车的地方。我会结合实际项目代码,把每一步的思路和坑都摊开讲。
2. 工具调用设计模式:从语义路由到函数调用
2.1 工具注册表与Function Calling机制
现在主流的模型平台基本都支持 function calling,也就是让模型根据你的工具描述,输出一个结构化的调用请求,而不是直接生成自然语言命令。这个机制的核心是工具注册表:一个机器可读的函数列表,每项包含函数名、描述、参数JSON Schema。
我见过不少新手直接把所有工具一股脑丢给模型,几十个函数塞进system prompt,结果模型频繁选错、参数乱填、上下文还白白占掉一大截。正确的做法是先注册,再筛选。把工具按业务域分类,每个类出几个“代表工具”参与第一轮路由,等模型确定大类后,再加载该域下的具体函数。
举一个实际例子。我们做内部运维助手时,定义了大概三十多个工具,如果全量注册,光工具描述就占了四千多token。后来改成两级路由:第一轮模型只选择“服务器操作”“日志查询”“数据库变更”等几个域,第二轮再映射到具体工具,准确率从68%直接跳到91%,token开销还降了快一半。
工具注册表的描述文本也有讲究。我踩过的坑是描述写得太模糊,比如“获取用户信息”,模型根本不知道要传什么参数。后来统一格式:
get_user_info: 根据用户ID或邮箱查询用户详细信息 参数: user_id (string, optional), email (string, optional) 注意: user_id 和 email 至少提供一个这样模型就能清晰判断该传什么、不该传什么。另外,描述里不要出现“你可以”“请尝试”这类废话,直接说功能和参数约束,模型更认。
2.2 语义路由:让模型决定调用哪个工具
工具调用的第一步不是直接设参,而是让模型判断“现在该不该调用工具、调用哪个”。这个动作就是语义路由。实现方式有两种:
- 第一种:把工具列表作为函数定义传给模型,由模型在生成回复时决定返回一个或多个调用请求。
- 第二种:先用一次轻量级调用做意图分类,命中后再走固定分支。
第一种更灵活,但存在不确定性;第二种更稳,但需要额外多一次模型调用。我个人的建议是,对于工具数量少于十个的简单场景,直接用第一种;对于工具多、误调用成本高的场景,切到第二种。
做路由的时候,还有个容易被忽略的点:模型可能会在没有合适的工具时强行选一个。这种情况下,一定要在指令里写清楚“如果无匹配工具,回复UNKNOWN”。否则模型会为了完成用户请求而去调用一个八竿子打不着的工具,轻则返回错误结果,重则触发权限敏感的变更操作。
2.3 参数解析与校验的边界
模型返回的调用请求本质上是一段JSON,但LLM生成的JSON偶尔会不合法。常见的是缺少逗号、布尔值大小写不对、字符串里残留注释。所以接住工具调用请求后,第一步永远是容错解析,而不是直接json.loads。
我自己习惯的做法是两层保险:
- 先用正则或解析器把代码块外的杂质去掉。
- 再用一个健壮的解析库,比如Python的json5或demjson3。
解析完成后,必须做Schema校验。这一步很多人会省,但省了迟早出事。模型生成的参数也许类型对,但值超范围、或者出现不可能的组合。比如查询时间范围的接口,start_time晚于end_time,如果直接透传给数据库,轻则报错,重则全表查询。
所以我在工具调用层加了一个轻量校验函数,每个工具的Schema里允许配置自定义校验规则。校验失败时,不直接报错,而是把校验错误信息返回给模型,让它重新生成参数。这一步对最终体验的提升非常明显——模型能自我纠错,而不用让用户重新说一遍。
2.4 调用失败重试与降级策略
工具本身也会挂。网络超时、权限不足、依赖服务返回5xx,这些在生产环境天天见。Agent系统里,工具调用失败的处理方式,和传统接口设计完全不同——不是简单的重试,而是要把失败信息回传给模型,让模型决定补救方案。
比如用户要查订单,订单系统超时。如果直接返回“系统错误”,体验很差。更合理的链路是:
- 工具调用超时,捕获异常。
- 将错误信息包装为“tool_result_error”,返回给模型,并附带建议重试标志。
- 模型判断是重试、还是换一个查询方式(比如查缓存)。
我给工具调用加了一个统一的异常格式:
{ "tool_name": "query_order", "status": "error", "error_code": "TIMEOUT", "error_message": "上游服务响应超时", "suggestion": "retry" }模型读到suggestion后,会生成“系统暂时繁忙,我再试一次”的过渡话术,同时尝试第二次调用。如果重试仍然失败,就降级:要么走缓存,要么给用户一个明确的降级提示。这个机制极大减少了交互中的“硬失败”。
3. 上下文管理设计模式:窗口滑动与记忆分层
3.1 上下文窗口压缩策略
Agent和普通聊天机器人最大的不同,是多轮对话里会穿插大量工具调用结果、中间状态、日志文本。这些内容如果不加控制,三五个来回就能把上下文窗口撑爆。所以上下文管理必须是一个独立的模块,而不是随手拼接字符串。
第一招是滑动窗口裁剪。保留最近N轮的用户输入和助手输出,更早的内容直接丢。但简单砍掉历史会丢失用户早期提到的关键信息,比如用户第一轮说“帮我查一下上周的订单”,后面一直在聊别的,到第五轮如果你把第一轮砍了,模型就不知道要查哪个订单了。
第二招是摘要压缩。每隔几轮,把前面的对话内容交给模型生成一段结构化的摘要,包括用户目标、关键约束、已执行动作、当前状态。然后把原始对话替换成摘要,再继续跑。这个做法的效果很稳定,代价是每几轮会多一次模型调用。说实话,这个成本值得花,因为保住关键信息比省token重要得多。
第三招是结构化存储。把对话中的实体和事实抽出来,存成JSON字段,查询时再放回去。比如用户提到“项目A的截止日期是周五”,压缩时可以只保留这句事实,而把其他无关讨论全部丢弃。
3.2 长期记忆与短期记忆分离
Agent系统里,短期记忆是当前对话的上下文,长期记忆则是跨会话持久化的用户画像和业务事实。两者如果混在一起,很快整个上下文都会被历史信息占满,当前任务反而被淹没。
我的做法是把记忆分成三层:
- 会话层:只存在于当前对话,存轮次记录和临时状态。
- 用户层:跨会话的偏好和长期事实,比如用户常说“回复尽量简洁”。
- 业务层:项目数据、外部系统同步来的关键实体状态。
每次新会话启动,不是把用户全部历史丢给模型,而是先做一个记忆召回。从用户层和业务层挑出与当前请求相关的记忆片段,转换成文本,拼进system prompt。其他无关记忆继续留在存储里,不占上下文。
这个思想跟RAG是一样的:不要试图把所有东西都塞进去,而是按需检索。我见过最实用的做法是用向量库存长期记忆,每次请求时用当前问题做向量相似度检索,取top5记忆片段。效果比全量搬运好得多,响应速度也快一大截。
3.3 向量检索与缓存的混合使用
长期记忆的召回离不开向量化,但向量检索不是万能的。比如用户习惯说“还是老样子”,这里的“老样子”完全无法通过相似度检索定位到历史订单。所以我会把检索和查询结合起来:先用关键词粗筛候选,再做向量精排,最后再用一个轻量模型判断哪些片段真正相关。
另外,上下文管理的另一个重要模式是结果缓存。对生成式AI来说,同样的请求重复命中时,其实不需要重新调用大模型。尤其是那些工具调用结果,比如用户连续几次查同一个接口,第一次查完后把结果放进缓存,后续直接返回,既省时间又省钱。
我自己习惯在三个层面做缓存:
- 接口层:相同用户、相同上下文内容的请求直接返回历史回复。
- 工具层:相同参数的只读工具调用结果,有效期几秒到几分钟。
- 模型层:对于确定性低的生成,用低温度重试;对确定性高的生成,直接走缓存。
缓存命中率一高,整个系统的压力立刻降下来。但要注意,缓存不能用在写操作和时效性敏感的场景,必须用业务状态来控制失效时间。
4. 实战:一个多工具智能体的核心实现
4.1 整体架构与运行流程
纸上谈兵那么多,接下来上一段可以直接参考的真实实现。我先说整体架构,再贴关键代码。这里不绑定某个具体框架,因为模式本身是通用的,你可以用LangChain、Semantic Kernel,甚至手写循环,核心思路都一样。
整个Agent的核心是一个循环:
- 接收用户输入。
- 加上当前上下文(系统提示、历史摘要、记忆召回)组成Prompt。
- 调用大模型,得到回复或工具调用请求。
- 如果是工具调用请求,执行工具,把结果追加到上下文,回到第2步。
- 如果是最终回复,返回用户。
这个循环看起来简单,但每一步都有很多边界条件要处理。下面我会暴露一些关键实现。
4.2 工具注册与调用的核心代码
先看工具注册的Python示例。这部分我特意写得精简,突出模式而不是框架细节。
from typing import Any, Dict, List import json class ToolRegistry: """工具注册表:管理所有可调用工具""" def __init__(self): self.tools: Dict[str, Dict[str, Any]] = {} def register(self, name: str, description: str, parameters: dict, func): self.tools[name] = { "name": name, "description": description, "parameters": parameters, "func": func, } def list_tool_schemas(self) -> List[dict]: """生成发给模型的工具描述列表""" return [ { "type": "function", "function": { "name": t["name"], "description": t["description"], "parameters": t["parameters"], } } for t in self.tools.values() ] def call(self, name: str, arguments: Dict[str, Any]): tool = self.tools.get(name) if not tool: raise KeyError(f"未知工具: {name}") return tool["func"](**arguments)然后是这个主循环。这里的重点在错误回传和迭代上限,这是防止Agent跑飞的关键。
def agent_loop(user_input, registry, memory, max_iterations=8): context = memory.build_context(user_input) for i in range(max_iterations): response = call_model([ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": context} ], tools=registry.list_tool_schemas()) if response.tool_calls: tool_name = response.tool_calls[0].name args = response.tool_calls[0].arguments try: # 关键:容错解析 args = json.loads(args) result = registry.call(tool_name, args) context += f"\n工具调用结果: {json.dumps(result, ensure_ascii=False)}" except Exception as e: # 关键:错误信息回传给模型,让它自己修正 context += f"\n工具调用错误: {e}" continue else: return response.content return "抱歉,我尝试了多次仍无法处理该请求,请稍后再试。"这段代码看着简单,已经包含了工具调用模式最核心的三个点:工具注册、容错解析、错误回传。实际项目里还会加鉴权、审计、超时控制,但骨架是一样的。
4.3 上下文压缩器实现细节
接下来是上下文管理的关键模块:上下文压缩器。它的作用是控制上下文长度,同时保留重要信息。
def compact_context(long_context: str, max_tokens: int = 3000) -> str: """当上下文超长时,用摘要压缩替代原始内容""" prompt = ( "请将以下对话历史压缩为结构化摘要,保留用户目标、" "关键约束、已执行动作、当前状态。不要丢失关键事实。\n\n" f"历史内容:\n{long_context}" ) summary = call_model([ {"role": "user", "content": prompt} ], temperature=0.1, max_tokens=max_tokens) return summary这个函数的调用时机需要设计好。我一般会在Agent循环内统计当前上下文的token数,超过阈值时才触发。阈值不是固定值,按模型上下文窗口的一半来算,比如8k窗口就设4k,16k窗口就设8k,留出足够的余量给后续新内容和新回复。
压缩后,原始对话历史会被替换成摘要。但要注意,摘要压缩本身也会丢失细节,所以我额外维护了一个memory对象,把关键实体和关系同步存储,压缩时再补回摘要里。这个做法虽然多费一点逻辑,但能显著减少信息丢失的概率。
4.4 记忆分层的落地实现
记忆分层的代码并不复杂,难点在设计存储结构。我习惯用三张表或者三个字典来存:
class Memory: def __init__(self): self.session = {} # 会话层 self.user = {} # 用户层,存偏好 self.business = {} # 业务层,存实体状态 def save_session(self, key, value): self.session[key] = value def save_user(self, key, value): self.user[key] = value def recall(self, query: str, k: int = 5) -> str: candidates = [] # 从所有层召回候选 candidates += [f"[用户] {v}" for v in self.user.values()] candidates += [f"[业务] {v}" for v in self.business.values()] # 简单关键词匹配打分,实际可以用向量检索 scores = [sum(q in c for q in query.split()) for c in candidates] top_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:k] return "\n".join([candidates[i] for i in top_idx if scores[i] > 0])这个简版用一个关键问题是想说明:记忆召回不必一上来就上向量库,很多时候关键词匹配已经够用。你把用户偏好、业务实体挨个存好,每次按当前问题挑几个出来,比全量塞给模型强得多。等到业务复杂了,再替换成向量召回,但召回前的过滤逻辑是一样的。
5. 常见问题与排查技巧实录
5.1 工具幻觉与循环调用
Agent开发最经典的问题是:模型明明没有合适的工具可用,却编造出一个不存在的函数,或者强行调用一个参数完全对不上的函数。这本质上是模型的幻觉。我的排查经验是,先看工具描述是否过于泛泛,再看是否设置了“UNKNOWN”兜底。如果都做了还出现幻觉,那就是温度参数的问题,把temperature调到0.1左右,幻觉概率会明显下降。
循环调用是另一个大坑。比如模型调用A工具,结果A返回错误,模型又调用A,反复横跳。我最快的一次线上事故就是Agent卡在循环里几分钟,白白烧了几百次调用。解决办法就是迭代上限,同时记录每次工具调用的历史和失败原因,如果是同一工具连续失败超过两次,就强制切换策略,不再让模型继续重试。
5.2 上下文溢出的三种表现
上下文溢出不止是报length exceeded,还经常表现为生成质量突然变差、工具调用忘带参数、答非所问。这些都是上下文太长导致模型的注意力被稀释。我的排查顺序是:先看token用量统计,再看上下文里有没有塞入过长的工具返回结果。
关键优化点在于工具结果摘要。很多工具的返回JSON动辄几千行,不能直接丢给模型。正确做法是对工具结果做字段裁剪、分页摘要或结构化摘要,只保留模型回答用户问题所需的最小信息。比如查询订单列表,只要返回订单号、状态、金额和时间,其他详情一律不塞入上下文。
5.3 可观测性与日志回放
Agent系统调试难度比传统接口高得多,因为每一步模型决策都是概率性的。你会发现上次跑通了,这次同样的输入却失败了。所以可观测性必须从第一行代码就开始做。
我每次都会记录完整的调用链日志:
- 输入文本
- 模型原始输出
- 解析后的工具调用请求
- 工具执行结果
- 上下文截断后的长度
- 每轮耗时和token数
有了这些日志,问题回溯基本就是打开日志看一遍的事。我甚至会把每次Agent运行的完整轨迹导出成HTML文件,方便团队成员直接在一个页面里看到每一步的前后因果。这个投入非常值得,能节省大量排查时间。
5.4 测试与回归评估
Agent的测试不能只盯着单轮输出的对错,要设计任务级测试集,每个任务包含多轮交互和工具调用。比如“用户要查订单,然后取消其中一个”这个任务,至少准备十几种变体,覆盖订单不存在、取消超时、权限不足等边界情况。
回归测试跑完后,需要关注两个指标:任务完成率和工具调用准确率。前者看最终用户目标是否达成,后者看模型有没有调用错误的工具。只把模型参数调一版,这两个指标可能会一升一降,这时候要结合业务场景取舍。我自己很看重完成率,因为它直接影响用户体验。
测试还可以考虑加入“对抗性输入”,比如用户故意把几个工具串在一起发出模糊指令。这类输入最能暴露路由和上下文管理的薄弱点,值得在测试集里专门维护一个目录。
最后分享一点我的实际体会
说实话,生成式AI设计模式这个话题,真正难的不是某个模式本身,而是把模式组合起来的时候怎么权衡。工具调用、上下文管理、记忆分层,每个单独拆开都讲得明白,一旦叠加在一起,约束就变得复杂起来。比如你为了缩短上下文做了摘要压缩,结果摘要里丢掉了上次工具调用结果的细节,下一轮模型就没办法根据细节做正确判断。这种“按下葫芦浮起瓢”的体验,我经历得太多。
后来我想明白一件事:设计模式不是用来减少问题数量的,而是把问题限制在可预测范围内。你用迭代上限防止死循环,用容错解析防止JSON崩掉,用记忆分层防止信息丢失。这些手段会让你付出一些额外的开发成本和模型调用成本,但换来的是系统的可控性。对一个要上线的Agent来说,可控比聪明重要得多。
如果你正在做自己的Agent项目,我建议不用一上来就追求最全的模式。先跑通一个最简单的循环,然后盯住日志里反复出现的那类错误,再针对性地加一个模式。这样一步步演进,比你照着某篇文章抄一遍要好使得多。生成式AI的变化太快,唯一能长期依赖的,就是那种随时能根据实际问题做取舍的能力。