如果你已经在 Spring Boot 项目里接入了大模型,大概率遇到过这两个场景:AI 聊着聊着就“失忆”,明明上一句刚说完订单号,下一句就开始瞎猜;再或者模型嘴皮子很溜,真要它去查个库、调个接口,它就是一动不动。这两个问题,放到 Spring AI 里正好对应两个核心模块——对话记忆(Chat Memory)和工具调用(Tool Calling)。这篇文章就围绕这两块,把我从“能对话”到“能干活”的完整思路写出来。
我会先讲清楚为什么大模型本来就不带记忆、工具调用又是靠什么机制跑通的,然后用可复现的 Spring AI 代码带你实现记忆管理、参数调优、@Tool 注解暴露方法,最后用一个带记忆的订单助手把两者串起来。内容会涉及 ChatMemory 接口、MessageChatMemoryAdvisor、ToolCallback、@ToolParam 这些关键点,同时结合我实际踩过的坑。适合已经在用 Spring AI 写业务代码、但还没系统梳理过记忆和工具的开发者,也适合准备做 AI Agent 方向、想搞清楚底层数据流的同学。
1. 为什么对话记忆和工具调用是接入大模型后的第一道坎
1.1 大模型 API 天生无状态:你必须自己解决“刚才说了什么”
大模型的对话接口本身是无状态的。你每次调 API,都要把一整段对话历史作为 messages 数组传过去,模型不会偷偷记住上一次你问了什么。从业务角度看,这反而是一种设计上的必然:接口保持简单、服务端不需要维护会话状态、横向扩展也容易。但到了应用层,这就非常反直觉了——用户天然觉得“我说过的你应该记得”。
所以在 Spring AI 里做对话记忆,本质只有一件事:把历史消息按正确的顺序组装好,并在每次请求时带上。你可以在业务代码里手动攒一个List<Message>,也可以借助框架的 Advisor 机制自动完成。很多初学者在这第一步就走偏了,要么把记忆挂在内存 Map 里不设上限,要么把所有历史无脑塞进请求,导致 token 成本失控。后面我会专门讲几种记忆策略怎么选。
1.2 工具调用:把模型从“嘴炮”变成“手脚”
大模型再聪明,它也只是文本生成器。它不知道你的数据库里有什么、不能帮你扣款、也不能真正调用第三方接口。工具调用(Tool Calling)解决的就是这个矛盾:当用户的问题超出模型自身知识范围、或者需要操作真实系统时,模型先输出一个“意图”,比如“我打算调用 queryOrder 方法,参数是 orderId=12345”,然后由你的代码真正执行这个调用,把结果返回给模型,模型再组织成用户能听懂的话。
这个过程也叫 Function Calling。Spring AI 把底层各家模型协议统一了,你写业务代码时基本不用关心 JSON Schema 怎么拼、tool_call 消息怎么解析,框架会帮你处理。但这不代表你可以完全躺着,工具描述写得好不好、参数 schema 是不是清晰、返回结果是不是“人话”,直接决定模型会不会乖乖调用、调用完能不能给出正确回复。这些细节才是真正考验工程能力的地方。
1.3 记忆和工具是配套的:缺一不可
我遇到过不少项目,单独看记忆和工具都“会了”,一旦组合起来就翻车。最典型的就是下面这种对话:
用户:帮我查一下订单 O20240101 到哪儿了。
助手:您的订单正在运输途中,预计明天送达。
用户:那它现在具体在哪个中转站?
第二句话里没有订单号,模型怎么知道“它”是谁?只能靠第一轮对话的上下文。如果没有记忆,模型就会自己编一个订单号去查,或者直接说“请提供订单号”。反过来,如果只有记忆没有工具,模型就算记得订单号,也没办法真的去查物流。所以这两个能力必须配套使用,而且顺序要捋清楚:先保证模型能从历史里拿到关键参数,再让它把参数交给工具去执行。
2. Spring AI 对话记忆:从手动拼历史到 ChatMemory Advisor
2.1 最朴素的“手动拼历史”到底行不行
先看一段最原始的写法。假设你已经有一个ChatModel,每次请求都只传用户最新消息:
String answer = chatClient.prompt() .user("帮我查一下订单") .call() .content();这样跑起来,模型每次都是“失忆”的。要让它记住上文,就得自己维护历史消息列表:
List<Message> history = new ArrayList<>(); history.add(new UserMessage("帮我查一下订单 O20240101 到哪儿了")); history.add(new AssistantMessage("您的订单正在运输途中,预计明天送达")); String answer = chatClient.prompt() .messages(history) .user("那它现在具体在哪个中转站?") .call() .content();这种写法不是不能用,在小 Demo 或者内部工具里反而最直观。但实际项目里它会迅速失控:一是你每次都要自己控制历史列表长度,二是多个用户共用同一段history就会出现串话,三是历史消息的保存位置、过期策略、清理时机全都得自己写。更麻烦的是,等你后面引入工具调用,模型执行工具时的中间消息也得塞进这个列表,手动维护极易出错。
所以,手动拼历史只适合用来理解“对话记忆到底在做什么”。一旦进入正规项目,直接用框架提供的ChatMemory抽象。
2.2 用 ChatMemory + Advisor 自动管理上下文
Spring AI 提供了一个ChatMemory接口,职责非常纯粹:按会话 ID 存取消息。核心方法就几个:
add(String conversationId, List<Message> messages):往某个会话里追加消息。get(String conversationId, int lastN):取出最近 N 条消息。clear(String conversationId):清空某个会话。
框架自带一个InMemoryChatMemory,消息存在 JVM 内存里。配合MessageChatMemoryAdvisor,你只要在构建ChatClient时声明一次,之后每次请求它都会自动把历史消息捞出来、拼接好、发给模型,再把这一轮的用户输入和模型输出写回记忆。代码长这样:
ChatMemory chatMemory = new InMemoryChatMemory(); ChatClient chatClient = ChatClient.builder(chatModel) .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory) .chatMemoryRetrievalSize(20) .build()) .build();chatMemoryRetrievalSize(20)是核心参数,表示每轮最多携带 20 条历史消息。这个数字不是越大越好,后面我会详细说怎么调。真正要记住的是:ChatMemory负责存储,Advisor 负责拼接,两者各管一头,接口设计得很干净。
2.3 记忆策略怎么选:窗口截断、摘要压缩、向量检索
很多人拿到retrievalSize就只会往大调,觉得“历史越多记得越清楚”。实际跑过你就知道,模型上下文窗口是有限的,而且每多一条历史,首字返回时间都会变长,token 成本也在涨。更关键的是,超过窗口上限后,最老的消息会被丢弃,丢失的信息是不会自己找回来的。
我整理过三种常见的记忆策略,适用场景完全不同:
| 策略 | 做法 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| 窗口截断 | 只保留最近 N 条消息 | 实现简单、token 可控 | 超出窗口的旧信息全丢 | 客服问答、短对话、Demo |
| 摘要压缩 | 把旧消息总结成一段摘要,随请求携带 | 保留长期信息、token 相对稳定 | 每次摘要要额外调一次模型,有延迟和成本 | 长对话、终身学习型助手 |
| 向量检索 | 将历史消息切片做 embedding,按相关度召回 | 可以从庞杂历史中捞关键信息 | 需要额外维护向量库、召回质量依赖打分 | 知识库问答、RAG 场景 |
Spring AI 里MessageChatMemoryAdvisor默认就是窗口截断策略,别的版本也提供了摘要类 Advisor 的变体。如果一个会话非常长,我更推荐做组合:最近 20 条原始消息完整保留,更早的消息压缩成一段摘要。这样短期记忆精准,长期记忆不至于完全丢失,成本也可控。但要注意,摘要压缩会引入额外一次模型调用,需要在成本和体验之间做取舍。
2.4 从 InMemory 到持久化:改造 ChatMemory 接入数据库
InMemoryChatMemory玩起来很爽,但上线第一天你就会发现问题:应用重启,记忆全没了;部署多个实例,用户请求落到不同机器,记忆互相不认。解决办法是让ChatMemory实现类把数据存进 Redis、数据库或者对象存储。
如果你不想引额外中间件,先落地一个数据库版本也很简单。核心是重写四个方法,把Message序列化后落库:
public class JdbcChatMemory implements ChatMemory { private final JdbcTemplate jdbcTemplate; private final ObjectMapper objectMapper; @Override public void add(String conversationId, List<Message> messages) { for (Message message : messages) { jdbcTemplate.update( "insert into chat_memory(conversation_id, role, content, create_time) values(?,?,?,?)", conversationId, message.getMessageType().name(), objectMapper.writeValueAsString(message.getText()), LocalDateTime.now() ); } } @Override public List<Message> get(String conversationId, int lastN) { List<Map<String, Object>> rows = jdbcTemplate.queryForList( """ select role, content from chat_memory where conversation_id = ? order by id desc limit ? """, conversationId, lastN ); // 反转列表并还原为 Message 对象 } @Override public void clear(String conversationId) { jdbcTemplate.update("delete from chat_memory where conversation_id = ?", conversationId); } }写这段代码时有几个细节容易踩坑。第一,查询时要用order by id desc limit ?拿最近 N 条,取回来后还要反转列表,因为消息顺序必须是先老后新。第二,表里要加conversation_id索引,否则会话一多,查询会越来越慢。第三,多实例并发写同一个会话时,可能出乱序,建议同一个会话的写入逻辑做串行化,或者在表结构里加版本字段做乐观锁。如果你确定要长期做 AI 应用,直接上 Redis 是性价比更高的选择,后面排查问题也方便。
3. Spring AI 工具调用:给模型装上可执行的手脚
3.1 用 @Tool 注解快速暴露方法:一分钟让模型学会“调接口”
Spring AI 最爽的一点是,给一个 Spring Bean 的公开方法标上@Tool注解,框架就会自动把它注册成工具,模型在对话过程中随时可以调用。比如做一个订单查询助手:
@Component public class OrderTools { @Tool(description = "根据订单号查询订单当前状态和物流信息") public String queryOrder(String orderId) { OrderInfo info = orderService.queryByOrderId(orderId); return "订单 " + orderId + " 当前状态:" + info.getStatus() + ",物流信息:" + info.getLogisticsDescription(); } @Tool(description = "取消指定订单,参数为订单号和取消原因") public String cancelOrder(String orderId, String reason) { boolean ok = orderService.cancel(orderId, reason); return ok ? "订单取消成功" : "取消失败,请检查订单号"; } }Spring AI 会扫描容器中所有带@Tool注解的公开方法,把它们注册成ToolCallback。然后在构建ChatClient时,直接用工具名引用:
ChatClient chatClient = ChatClient.builder(chatModel) .defaultTools("queryOrder", "cancelOrder") .build();注意一个细节:@Tool默认以方法名作为工具名,如果你的多个类里有同名方法,工具注册会冲突。所以方法名要起得足够语义化,比如queryOrder和cancelOrder这种,一看就明白是干嘛的。如果确实需要自定义工具名,后面直接实现ToolCallback接口会更灵活。
3.2 为什么工具描述比工具实现更重要:参数 Schema 是模型的操作说明书
很多开发者把工具写好了,模型就是不调用,或者调用得乱七八糟。问题往往不出在 Java 方法上,而出在描述和参数 schema 上。模型不是人,它看到的是一个 JSON Schema,你要把它需要理解的一切都写进描述里。
我见过一个反例,方法签名是这样的:
@Tool public String query(String q) { // 查询库存 }方法叫query,参数叫q,没有任何描述。模型根本不知道“q”应该传订单号还是商品编号,也不知道这个方法在什么场景下该调用,结果就是要么不调,要么疯狂报错。
正确的写法是给方法和参数都加上明确说明。Spring AI 里参数描述用@ToolParam:
@Component public class InventoryTools { @Tool(description = "根据商品编号查询实时库存数量,库存不足时不可下单") public String queryStock(@ToolParam(description = "商品唯一编号,如 SKU123456") String skuId) { int stock = inventoryService.queryStock(skuId); return "商品 " + skuId + " 当前库存:" + stock + " 件"; } }再强调一次:description不是写给代码维护者看的,是写给“模型”看的。你应该在里面写清楚什么时候该调用这个工具、参数代表什么、返回结果要如何理解。比如“库存不足时不可下单”这句话,就是在引导模型在库存不足时不要替用户做出错误的承诺。项目里工具一多,描述写得好不好,直接决定模型能不能精准地选对工具。
3.3 ToolCallback 手动注册:当你需要完全控制工具定义时
@Tool注解虽然快,但有些场景它不够用。比如你的工具列表是动态从数据库加载的,或者同一个工具要适配不同的参数组合,再或者你需要给工具定义附加上下文信息。这时候手动实现ToolCallback是更可靠的方式。
Spring AI 的ToolCallback接口核心方法有四个:getName()返回工具名,getDescription()返回总描述,getToolDefinition()返回完整的 JSON Schema 定义,call(String toolInput)接收模型生成的参数并执行。
public class WeatherTool implements ToolCallback { @Override public String getName() { return "queryWeather"; } @Override public String getDescription() { return "查询用户指定城市的实时天气,包括温度和天气现象。"; } @Override public String getToolDefinition() { return """ { "type": "function", "function": { "name": "queryWeather", "description": "查询用户指定城市的实时天气,包括温度和天气现象。", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,如北京、上海" } }, "required": ["city"] } } } """; } @Override public String call(String toolInput) { // toolInput 是模型生成的 JSON,例如 {"city":"北京"} String city = extractCityFromJson(toolInput); WeatherInfo weather = weatherClient.fetch(city); return city + "当前天气:" + weather.getCondition() + ",温度:" + weather.getTemperature() + " 度"; } }注册到ChatClient时也简单:
ChatClient chatClient = ChatClient.builder(chatModel) .defaultTools(new WeatherTool()) .build();这个写法的优势在你的工具参数会动态变化时特别明显:你可以在getToolDefinition()里根据后端配置动态生成 schema,而不是写死注解。代价是你要自己保证 JSON Schema 语法正确、参数类型和解析逻辑一致。对我个人来说,稳定的业务工具用@Tool,需要动态配置和复杂校验的场景用ToolCallback,两者配合已经覆盖了绝大多数需求。
3.4 工具调用闭环里最容易出错的三个点
工具调用的完整过程可以理解成一个循环:模型收到用户问题后,如果决定调用工具,会返回一个“工具调用意图”(包含工具名和参数);框架读取这个意图,执行你注册的call方法;执行结果作为一条新消息回传给模型;模型再基于工具结果生成最终回复。这个循环可能反复多次,比如一个工具的结果需要接着调另一个工具。Spring AI 的ChatClient会在一次请求里自动循环处理,直到模型不再要求调用工具。
这个机制跑起来很快,但有三个点特别容易翻车。
第一,工具返回结果必须“说人话”。如果call方法返回的是内部 JSON 字段,比如{"status": "SHIPPING", "code": "01"},模型很可能会原样照搬,用户看到的就是一堆没头没尾的字段。你应当在工具方法内部就把它组织成自然语言,比如“订单已发货,当前在 XX 中转站,预计明天 18:00 前送达”。模型拿到这种结果,几乎不需要润色就能直接回复用户。
第二,工具内部必须做异常兜底。如果工具方法抛异常,框架通常会把异常信息传给模型,模型可能一本正经地编一个“抱歉,系统出错了”的回复,但用户根本不知道真实原因。更稳的做法是在call内部捕获所有异常,返回一个可理解的失败信息:“查询失败:订单号不存在,请用户确认后重试”。
第三,工具循环有次数上限。模型可以一直要求调用工具,但如果它陷入死循环,你的请求会一直不返回。框架通常有默认的循环限制,超过后会终止。项目上线前一定要做个压力测试,看一个会话最多会有几次工具调用,把限制调到一个合理值,避免被用户的一句提问拖垮性能和成本。
4. 一个带记忆的订单助手:把对话记忆和工具调用真正组装起来
4.1 场景设定:用户不提订单号,模型也能继续查
前面讲了那么多,接下来做一个场景来串一下。假设做一个订单助手,用户可以在网页或者 IM 里连续提问。第一个问题是“帮我查一下订单 O20240101 到哪儿了”,模型调用工具返回物流状态。第二个问题用户只说“那它现在具体在哪个中转站”,这时候模型必须记得上一个问题里的订单号,才能正确发起第二次工具调用。
这个场景同时考验记忆和工具两件事:没有记忆,模型不知道“它”是谁;没有工具,模型就算记得订单号也没法查。实现思路是让所有请求落到同一个ChatClient上,通过conversationId区分不同用户会话,MessageChatMemoryAdvisor负责保存和注入历史,@Tool方法负责查询最新物流。
4.2 完整代码实现:配置、Controller、工具三件套
先看基础设施配置。用ChatMemory和ChatClient两个 Bean 把记忆和工具串起来:
@Configuration public class AiConfig { @Bean public ChatMemory chatMemory() { return new InMemoryChatMemory(); } @Bean public ChatClient chatClient(ChatModel chatModel, ChatMemory chatMemory) { return ChatClient.builder(chatModel) .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory) .chatMemoryRetrievalSize(20) .build()) .defaultTools(new OrderTools()) .build(); } }注意这里ChatModel是由你引入的 Spring AI 模型自动配置提供的,不同服务商的 starter 会自动装配对应的实现。只要 pom 里引入了对应依赖,这里就能直接注入。
然后是订单查询工具,参数用@ToolParam写明含义:
@Component public class OrderTools { @Tool(description = "根据订单号查询订单当前状态和物流信息,订单号通常以 O 开头") public String queryOrder(@ToolParam(description = "订单号,如 O20240101") String orderId) { OrderInfo info = orderService.queryByOrderId(orderId); if (info == null) { return "没有找到订单 " + orderId + ",请让用户核对订单号"; } return "订单 " + orderId + " 当前状态:" + info.getStatus() + "," + info.getLogisticsDescription(); } }最后是 Controller 接口,接收用户消息和会话 ID:
@RestController public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient chatClient) { this.chatClient = chatClient; } @PostMapping("/chat") public Map<String, String> chat(@RequestBody ChatRequest request) { String answer = chatClient.prompt() .user(request.getMessage()) .advisors(a -> a.param("chat_memory_conversation_id", request.getConversationId())) .call() .content(); return Map.of("answer", answer); } }这里有一个容易踩的坑:MessageChatMemoryAdvisor判断会话 ID 用的参数名是chat_memory_conversation_id,如果你漏掉.advisors(a -> a.param(...))这一段,框架会拿一个默认的会话 ID 存记忆,所有用户都会串到同一个上下文里。实际项目中我建议在构建ChatClient时用defaultAdvisors把默认会话 ID 配置好,再在接口层覆盖,这样即使某个请求忘了传会话 ID,也不会出现全局串话的灾难。
4.3 联动体验里最容易翻车:工具结果要不要进记忆
很多人在做完记忆和工具联动后会遇到一个奇怪现象:第一轮用户问了订单号,助手也查了;第二轮用户换个话题问了天气,然后再回来问订单,模型居然忘了。这背后的原因通常是工具调用后的中间消息没有被回写进记忆,或者retrievalSize太小,把关键历史裁掉了。
设计时要想清楚一件事:哪些消息应该进入记忆,哪些不应该。用户问题、助手最终回复,这两类必须进。工具调用的中间过程,比如“模型决定调 queryOrder,参数是 O20240101”“工具返回了物流信息”,这些属于内部数据流,一般不直接进长期记忆。因为最终回复已经包含了用户需要的信息,保留中间过程反而浪费上下文空间。但在排障时,你需要在日志里把这些中间过程完整记录下来,这跟记忆存储是两回事。
这里我建议每轮请求的日志都打印三样内容:传入模型的 messages 摘要、模型是否发起了工具调用、最终返回给用户的内容。只有把这三样串起来,出问题时你才能快速判断是记忆没带上、工具没查到、还是模型胡编。
4.4 上下文策略的取值经验:多少条历史最合适
我见过一上来就把chatMemoryRetrievalSize设成 100 的,结果首字延迟暴涨。说实话,多大合适没有标准答案,但有两个经验可以分享。
第一,普通客服场景,最近 10 到 20 条对话已经能覆盖绝大多数需求。用户很少会突然引用 50 轮之前的细节,多数情况是在当前话题内追问。第二,如果你的对话时间长且问题复杂,单纯加大窗口不如做摘要。一个稳定的组合是:最近 20 条完整消息 + 更早部分压缩成摘要。这样模型既有精确的短期记忆,又有可用的长期线索。
另外,每条消息本身也有长度差异。一个工具查询结果可能几百字,用户的一条问题可能只有几个字。记忆数条数而不是数字符,对 token 的控制是不够精确的。如果追求极致成本控制,可以自己实现一个ChatMemory,按整轮对话的 token 总量做裁剪,而不是简单按条数。
5. 常见问题与排查技巧实录
5.1 AI 莫名其妙失忆的六个原因
“为什么我明明配了 Advisor,模型还是记不住?”这个问题我在社区里见到过太多次。排查顺序建议从这六个方向过一遍:
- 会话 ID 没有固定。每次请求都生成新的
conversationId,记忆就永远建在同一个新空房间里。 retrievalSize设置了但取值太小,比如 1,历史被裁得只剩最新一轮。- 两个不同的
ChatClient实例各配各的记忆,用户请求分散到多个实例。 - Advisor 顺序配置出错。
MessageChatMemoryAdvisor最好放在其他顾问之前,否则别的 Advisor 先修改了 Prompt,再叠加记忆,可能把关键上下文挤掉。 - 代码里用了
.messages(自定义列表)覆盖了默认注入的历史。当你手动传 messages 时,会自动跳过 Advisor 注入的历史。 - 用户消息经过模板改写,历史里存的原始问题;下次模型看到的上下文跟用户真实意图错位,表现为“答非所问”而不是“失忆”。
5.2 工具方法没被模型调用的五个原因
工具注册成功了,但模型就是不用。不要急着怀疑框架,优先检查这五件事:
- 模型本身是否支持工具调用。有些模型能力较弱,或者你接入的模型端没有开启 function calling 配置。
- 工具描述太模糊。模型不知道什么时候该用它,自然不会主动调。
- 参数定义不完整。JSON Schema 里缺少必填字段说明,模型生成的参数不合法,调用直接失败。
- 工具方法权限不对。
@Tool标注的方法必须是在 Spring 容器管理的 Bean 中的 public 方法,private 方法不会被扫描。 - 当前模型上下文太短。工具返回结果太长被截断,模型看不到完整执行结果,就会放弃继续使用工具。
排查时打开日志看两处:发给模型的请求里,tools字段是否包含你的工具定义;模型返回里有没有tool_calls标记。这两处有其一缺失,问题范围立刻缩小一半。
5.3 工具明明返回了,模型却说“查不到”
表面上是模型的问题,根源多数出在工具结果的传递环节。Spring AI 默认会把工具结果作为一条消息回传给模型,但要确认你没有在 Advisor 里对这轮消息做裁剪。另一个常见原因是工具返回的文本质量太差,比如返回一个空对象{},模型读不出任何信息,只能回答“查不到”。
我的建议是:工具结果中所有分支都要写清楚。查到数据,就把关键信息用自然语言写出来;查不到,就明确说“未查到该订单,请核对订单号”。不要让模型去猜一个空结果的含义。
5.4 生产环境的记忆异常与性能问题
上线前你可能觉得记忆很简单,上线后问题全冒出来了。最常见的几个我列在下面:
| 症状 | 最常见原因 | 优先检查项 | 解决方案 |
|---|---|---|---|
| 多实例下记忆不互通 | 用了InMemoryChatMemory | 确认会话 ID 落点 | 改用 Redis 或数据库实现ChatMemory |
| 响应越来越慢 | 历史消息过多、携带了大量无用上下文 | 打印请求消息条数和 token 量 | 缩小窗口,增加摘要策略 |
| 用户会话被串话 | conversationId为空或默认值 | 检查 Controller 是否传参 | 强制校验会话 ID,缺失则拒绝请求 |
| 记忆表无限增长 | 没有清理策略 | 查看聊天记录表数据量 | 定期归档/删除过期会话数据 |
| 并发写同一会话冲突 | 多条消息同时写入 | 查看日志是否有锁等待 | 同会话串行写入,或加版本字段 |
5.5 最后分享一点脚手架之外的经验
项目做多了你会发现,Spring AI 的 API 其实没那么复杂,复杂的是你的数据流和组织方式。我个人的体会是:把每个会话的一次请求看作一条完整的事件流,从用户输入、到记忆注入、到工具调用、再到最终回复,每一步都留痕。这样即使出了问题,你也能很快定位是记忆没带上、工具没执行、还是模型乱编。我在一套模拟项目 X 里就把这个过程完整记录了日志,后续排查效率提升非常明显。
另外,对话记忆别贪多。不是所有历史都值得带进上下文,很多旧信息是噪音。我踩过几次坑之后,现在基本都给retrievalSize设 15 到 20,并配合摘要策略兜底。省出来的 token 和延迟,远远比堆更多历史有价值。工具调用同理,一个请求里塞十几个工具,模型选错工具的概率会明显上升,少而精才是正确方向。这些细节不会写在官方文档里,但恰恰决定了你的 AI 助手在用户手里是“聪明”还是“人工智障”。