N+1 查询问题已经感染了你的 AI Agent
你的 AI Agent 刚刚为了回答一个只需要两次调用的问题,进行了十二次 API 调用。你没有注意到这一点,因为工具调用没有 EXPLAIN ANALYZE,没有 ORM 分析器来标记问题,而且 Agent 最终还是得到了正确答案 —— 只是晚了两秒钟,且 Token 预算超支了三倍。
这就是 N+1 查询问题,它已悄然从数据库层迁移到了你的 Agent 工具调用层。坏消息是:这种故障模式与 2010 年代毒害 Web 应用程序的模式完全相同。好消息是:那个时代的解决方案几乎可以直接移植过来。
什么是 N+1 问题
在 ORM 时代,N+1 问题是这样的:一个页面列出 100 篇博客文章,每篇都显示作者姓名。ORM 在一次查询中获取文章列表 —— 这就是那个 1。然后,对于每篇文章,它会延迟加载(lazy load)作者信息,每次都在单独的查询中进行 —— 这些就是那个 N。100 篇文章,101 次数据库往返,导致页面加载极其缓慢。
修复方案并非巧妙的缓存或重试 逻辑,而是重新设计数据访问层:通过 JOIN 进行预加载(eager loading),或者使用 Facebook 在 2015 年推出的 DataLoader 模式,该模式将单个事件循环周期(event-loop tick)内的所有 .load(key) 调用合并为一次批量抓取。调用者的 API 保持不变;数据层在底层进行了合并。
这个模式之所以令人难忘,是因为在达到一定规模之前,其症状是不可见的。一条记录运行良好。十条记录也行。一万条记录时,一切突然崩溃 —— 当你进行性能分析时,你会发现成千上万个近乎相同的查询,而这些本可以合并为一个。
这种模式如何重新出现在工具调用层
一个对包含 10 个用户的列表进行推理的 AI Agent,不会通过 JOIN 来加载他们。它会调用一次 get_user_profile(id),处理结果,简短推理,然后为下一个用户再次调用。重复十次。同样的结构性错误,只是换了个媒介。
一个记录在案的例子:一个负责总结股票相关新闻的 Agent 发起 getStockPrice('AAPL'),等待结果,进行推理(消耗约 500 个 Token),然后调用 searchNews('Apple'),再次推理(再消耗 400 个 Token),最后给出最终答案。总计:1,400 个 Token 和两次顺序 API 调用。高效版本在推理前并行发起这两个调用 —— 550 个 Token,同样的调用,减少了 60% 的开销。
这种延迟加载反模式在 Agent 层以三种截然不同的形式出现:
顺序单项抓取。 五个独立的工具调用,每个耗时 200 毫秒,就是一整秒的阻塞等待 —— 这与五个本可以合并为一个的数据库查询的计算逻辑完全相同。在一个包含天气-股票-餐厅-距离查询的四工具基准测试中,顺序执行耗时 4.4 秒,而并行执行仅需 1.5 秒 —— 延迟降低了 70%,其耗时受限于最慢的单次调用,而非所有调用的总和。
冗余重复抓取。 在长上下文窗口中运行的 Agent 经常在一次会话中多次调用参数相同的同一个工具,因为它们没有维护请求范围内的记忆层(memoization layer)。LLM 生成一个调用,它消失在上下文中,三步之后,模型再次生成相同的调用。如果没有去重层,工具响应的每个 Token 都会被加载到上下文窗口两次。
向上下文窗口过度抓取。 一个只需要联系人电子邮件地址的 Agent 调用 get_contact(id),却收到了一个包含地址历史、购买记录和元数据的 2,000 Token 数据块。现在,在随后的每个推理步骤中,整个数据块都存在于上下文中。在一个记录在案的材料科学工作流案例中,工具输出不断积累,直到上下文溢出导致会话中断 —— 消耗了两千万个 Token 却以失败告终。一种将大型工具结果存储在状态存储中并仅注入引用的内存指针模式(memory pointer pattern),将开销减少到 1,234 个 Token 并获得了成功。
问题出在工具设计,而非 Agent
这里有一个令人不安的含义:当你分析传统 Web 应用中的 N+1 问题时,你不会通过告诉开发人员“多思考”来修复它。你会修复数据访问层。这 同样适用于 Agent。
一个在循环中调用 get_user_profile(id) 的 Agent 并没有发生故障。它只是在调用你提供给它的唯一工具。如果工具的 Schema 只暴露了单数接口,那么顺序循环就是合理的响应。Agent 无法合并 API 不支持的操作。
修复应从工具设计开始:
提供批量端点并提示优先使用。 将 get_user_profile(id: str) 替换为 get_user_profiles(ids: list[str]),并在描述中加入:“获取多个用户时,务必进行批量处理。” LLM 在规划调用时会参考工具描述。一个明确不鼓励循环的描述会改变模型构建其计划的方式。
用搜索工具替换列表工具。 一个返回所有联系人的 list_contacts 工具会使上下文窗口充斥着 Agent 不需要的一百条记录。而一个在服务器端进行过滤的 search_contacts(query: str, limit: int) 工具则返回目标子集。Agent 无法有效地进行分页 —— 它们的设计初衷并非跨轮次跟踪游标状态。设计将过滤带到数据所在地的工具。
构建隐藏多步操作的粗粒度工具。 一个 are_these_users_active(ids: list[str]) -> dict 工具将三个后端调用(获取用户记录、检查最后活动时间戳、关联订阅状态)隐藏在一个面向 Agent 的接口后面。与其封装单个 REST 端点,不如封装业务意图。Agent 发起一次调用;你的工具根据需要发起多次后端调用。
增加投影参数。 一个 fields: list[str] 参数允许 Agent 仅请求所需的属性。这是通过合约进行的字段投影 —— Agent 可以请求 ["email", "name"] 并收到两个字段而非五十个,从而保持工具响应精简,使 Token 成本与实际数据使用成正比。
移植到 Agent 的 DataLoader 模式
当你需要一个统一的接口(因为重构 Agent 的工具 Schema 不切实际)时,DataLoader 批处理垫片(shim)可以直接应用于编排层。
原始的 DataLoader 收集在单个事件循环 tick 内发出的所有独立 load(key) 调用,然后将它们作为一个批次触发。编排层中的等效做法是:当 LLM 响应针对具有不同参数的同一工具发出多个工具调用时,在执行前收集所有调用,触发单个批量 API 请求,并将结果分发回每个调用点。Agent 看到的是独立的调用结果;而基础设施层处理了合并过程。
这正是斯坦福大学 SqueezeAI 实验室的框架 LLMCompiler 明确在做的事情。一个规划模块发出工具调用的依赖图,标记哪些是独立的。任务获取单元并行分发所有独立任务,而不是按顺序执行。与基准 ReAct Agent 相比:端到端速度快了 3.7 倍,Token 成本降低了 6.7 倍,准确率提高了约 9%,因为在编排开销上消耗的推理 Token 更少。
延迟的提升源于数据库优化中一贯的原理:消除往返乘法效应(round-trip multiplication factor)。如果四个工具调用各需 200 毫秒且互不依赖,顺序运行需要 800 毫秒。并行运行只需 200 毫秒加上协调开销。协调开销永远不会接近单次调用的延迟。
缓存作为第二道防线
记忆化(Memoization)是 DataLoader 模式的另一 半——不仅要批处理,还要避免重复。同样的响应需要在多个层级应用缓存策略。
请求范围的记忆化是最廉价的收益:在单个 Agent 回合内,跟踪哪些工具调用已经执行及其结果。任何使用相同参数对同一工具的重复调用都将返回缓存结果,而无需网络请求。这与 DataLoader 的按请求缓存直接对应,防止在一次执行中同一 Key 两次进入批处理函数。
提示词缓存(Prompt caching)能高效处理静态上下文。工具 Schema 本身开销巨大——一组 29 个工具的 Schema 可能达到数千个 Token,并出现在每条消息中。前缀缓存将这些静态部分标记为可缓存,将随后的读取成本降低到写入成本的约 10%。对于拥有大量 Schema 库存的 Agent,预先在缓存 Schema 块上的投入会在随后的每一轮对话中获得回报。
语义工具预过滤解决了相关的过度注入问题:与其在每个上下文窗口中包含所有工具 Schema,不如嵌入工具描述并针对给定查询检索前 K 个最相关的工具。在 AWS 研究的一个生产部署中,这种方法将每个查询的 Token 从 1,557 个减少到 275 个(减少了 89%),同时由于模型不会被无关工具分散注意力,准确率反而从 75.8% 提高到 82.3%。预计大规模生产环境每月可节省 60,000 美元。
Agent 计划缓存是最高杠杆的层级。与其缓存单个工具响应,不如缓存执行计划——即如何拆解一类查询的任务图。最近的一种方法从完成的 Agent 执行中提取结构化计划模板,并通过关键词相似度将其与新请求匹配。在 GAIA 基准测试中,每个查询的成本从 69 美元降至 16 美元(降低了 76%)。使用 100 条条目的缓存实现了 46% 的命中率,这表明许多生产查询都聚集在少数常见的意图形状中。
如何发现系统中的问题
对于 Agent 工具调用,相当于 EXPLAIN ANALYZE 的工具是对话追踪(Trace)检查。LangSmith、AgentOps 和经过 OpenTelemetry 检测的 Agent 运行时可以生成工具调用序列的瀑布视图。N+1 的特征清晰可见:一叠几乎相同的工具调用,具有相同的函数名,每轮交错出现一个,像梯子一样向下延伸。
追踪中的三个信号预示着问题:
使用不同的单个 Key 顺序调用同一个工具。 如果 get_product_details 带着五个不同的产品 ID 连续出现五次,你就有了批处理的机会。
在一次会话中以相同参数调用同一个工具。 重复调用相同参数表明缺少记忆化。第二次调用的 Token 完全是浪费。
工具响应的大小远超下游实际使用的数据。 如果一个工具返回了 3,000 个 Token,但 Agent 的下一个推理步骤只引用了结果中的一个字段,你就有了过度获取(Over-fetching)的优化空间。
这些都不需要增加基础设施。它们只需要你在阅读现有的追踪记录时留意 N+1 模式——就像后端工程师学习阅读慢查询日志一样。
优化层级
投入精力的先后顺序至关重要:
首先,修复工具接口。批处理工具 API 从源头上解决了 N+1 问题。这始终是最高杠杆的修复方案,因为它为每个使用该工具的 Agent 消除了问题,而不仅仅是针对一种查询模式。
其 次,增加编排层的并行性。对于无法合并到单个 API 中的独立工具调用(例如调用不同的外部服务),并行分发可将延迟降低到最慢的单次调用。大多数 Agent 框架都支持将并行工具调用作为配置标志;默认的顺序执行是早期 API 设计的遗产,而非固有的限制。
第三,在会话级别实施记忆化。请求范围的结果缓存不需要服务端更改——它完全在 Agent 的执行上下文中运行,跟踪调用并返回存储的结果。
第四,对你的工具库应用语义预过滤。随着工具数量增长到十个或二十个以上,Schema 注入开销开始与 Agent 实际的推理预算争夺上下文空间。相关工具的语义检索使这一成本呈亚线性增长。
每个分析过慢接口的 Web 工程师都发现过 N+1 模式。这种调试直觉已经存在——它只需要一个新的上下文。你的 Agent 工具调用追踪就是你的查询日志。开始那样去阅读它吧。
- https://www.codeant.ai/blogs/poor-tool-calling-llm-cost-latency
- https://arxiv.org/abs/2312.04511
- https://arxiv.org/html/2506.14852v2
- https://dev.to/aws/reduce-agent-errors-and-token-costs-with-semantic-tool-selection-7mf
- https://medium.com/@gor17v/parallel-tool-execution-with-claude-4-building-high-performing-agents-with-bedrock-converse-api-433da0efab60
- https://www.anthropic.com/engineering/writing-tools-for-agents
- https://wundergraph.com/blog/dataloader_3_0_breadth_first_data_loading
- https://medium.com/@sohamghosh_23912/8-production-patterns-for-token-efficient-agentic-ai-3764030a81c3
- https://dev.to/aws/why-ai-agents-fail-3-failure-modes-that-cost-you-tokens-and-time-1flb
- https://dev.to/willvelida/preventing-cascading-failures-in-ai-agents-p3c
