ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

AI Agent Memory 和 Chat History 区别:别把记忆当成聊天记录表,TaoToken 统一 Key 接入 Spring AI ChatMemory 实践

AI Agent Memory 和 Chat History 区别:别把记忆当成聊天记录表,TaoToken 统一 Key 接入 Spring AI ChatMemory 实践 1. 从一次线上事故说起为什么 Memory 不能当聊天记录表先说一个我踩过的坑。去年做一个客服 Agent产品需求是「用户能在侧边栏回看完整对话」。当时系统里已经接了 Spring AI 的ChatMemory我图省事直接让前端调接口从ChatMemoryRepository里读历史。测试环境聊了三四轮一切正常。上线第二天客服反馈用户翻到第 12 轮前面的对话全没了只剩最近 10 轮左右。排查下来原因很直接MessageWindowChatMemory默认maxMessages是 20 条消息一轮问答占 2 条也就是最近 10 轮。超出窗口的早期消息被移出Repository里维护的只是「当前记忆窗口」不是完整档案。用户要看的完整历史压根就不该从 Memory 里取。这就是 AI Agent Memory 和 Chat History 最容易被混淆的地方。很多人做 Agent 时第一反应是「把聊过的话存下来就是记忆」听起来没错但真做项目时这个理解会把系统设计带偏。Memory 和 Chat History 都可能存消息但用途完全不同Memory给模型看的上下文策略关心「下次调用要带什么」。它可以滑动、压缩、过滤、摘要。Chat History给业务看的完整记录关心「完整记录是什么」。它要完整、可查、可审计。这篇就围绕 Spring AI 的ChatMemory展开讲清楚边界给出可复制的配置片段、多轮对话验证脚本以及通过 TaoToken 统一 Key 接入 API 的 Base URL 与鉴权配置验证记忆读写与历史回放互不干扰。适合正在用 Spring AI 做 Agent、被「记忆」和「历史」绕晕的后端同学。2. TaoToken 前置准备统一 Key 接入 Spring AI 的 Base URL 与鉴权在写ChatMemory配置之前先把模型接入这层搞定。Spring AI 支持 OpenAI 兼容协议只要把 Base URL 和 API Key 配对就能用统一的OpenAiChatModel调不同模型。我用 TaoToken 做统一 Key 接入好处是一个 Key 管多个模型切换模型不用改代码结构只改model参数。先拿 Key。打开官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 注册后在控制台创建 API Key。控制台地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite Key 列表在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。创建完记得复制保存页面刷新后不再完整显示。接入信息三件套先记牢配置项值Base URLhttps://taotoken.net/apiAPI Key控制台创建的sk-开头 KeyModel ID例如gpt-4o-mini、claude-3-5-sonnet等按控制台可用列表填注意 Base URL 是https://taotoken.net/api不带 UTM 参数这是给程序调用的地址。Spring AI 的 OpenAI starter 会在 Base URL 后面自动拼/v1/chat/completions这类路径所以配置里填到/api即可不要自己再加/v1否则会拼成/api/v1/v1/...报 404。依赖方面pom.xml里加 Spring AI 的 OpenAI starter 和 Web starterdependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId version1.1.7/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependencyapplication.yml里配置接入信息Key 建议走环境变量别硬编码进仓库spring: ai: openai: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} chat: options: model: gpt-4o-mini temperature: 0.7启动前把环境变量设好Linux/macOS 用export TAOTOKEN_API_KEYsk-你的KeyWindows PowerShell 用$env:TAOTOKEN_API_KEYsk-你的Key。这一步做完模型调用这层就通了接下来才是重点把 Memory 和 Chat History 分开设计。3. 可复制配置ChatMemory 与业务 Chat History 表分离这一节给可直接抄的配置。核心思路一句话ChatMemory走模型上下文业务表走完整留痕两者共用conversationId关联但读取方式完全分开。先配ChatMemory。Spring AI 1.1.7 里MessageWindowChatMemory默认maxMessages是 20 条消息不是 20 轮。一轮问答通常一条用户消息加一条助手回复所以 20 条约等于最近 10 轮。超过窗口后较早的非SystemMessage会被移出SystemMessage保留如果加入新的SystemMessage旧的会被替换。import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.memory.MessageWindowChatMemory; import org.springframework.ai.chat.memory.ChatMemoryRepository; import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class MemoryConfig { Bean public ChatMemoryRepository chatMemoryRepository() { // 生产环境换成 JDBC / Redis 实现这里用内存版演示 return new InMemoryChatMemoryRepository(); } Bean public ChatMemory chatMemory(ChatMemoryRepository repository) { return MessageWindowChatMemory.builder() .maxMessages(20) // 20 条消息 ≈ 最近 10 轮 .chatMemoryRepository(repository) .build(); } }如果你要持久化记忆窗口把InMemoryChatMemoryRepository换成 JDBC 实现建表语句大致如下。注意这张表存的是「记忆窗口」不是完整历史CREATE TABLE ai_chat_memory ( conversation_id VARCHAR(64) NOT NULL, message_type VARCHAR(32) NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_conversation (conversation_id) );业务 Chat History 表单独建字段更全包含用户、会话、来源、工具调用结果等用于页面回看、质检、审计CREATE TABLE biz_chat_history ( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, conversation_id VARCHAR(64) NOT NULL, user_id VARCHAR(64) NOT NULL, role VARCHAR(16) NOT NULL, -- user / assistant / tool content TEXT NOT NULL, source VARCHAR(32), -- web / app / api created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_conv_user (conversation_id, user_id) );调用侧的分工要写清楚。模型调用走 Memory业务留痕走 History两条链路各写各的Service public class ChatService { private final ChatClient chatClient; private final ChatMemory chatMemory; private final BizChatHistoryMapper historyMapper; public ChatService(ChatClient.Builder builder, ChatMemory chatMemory, BizChatHistoryMapper historyMapper) { this.chatClient builder.build(); this.chatMemory chatMemory; this.historyMapper historyMapper; } public String chat(String conversationId, String userId, String question) { // 1. 业务留痕用户消息先落完整历史表 historyMapper.insert(conversationId, userId, user, question, web); // 2. 模型调用走 Memoryadvisors 自动读写记忆窗口 String answer chatClient.prompt() .user(question) .advisors(a - a.param(ChatMemory.CONVERSATION_ID, conversationId)) .call() .content(); // 3. 业务留痕助手回复也落完整历史表 historyMapper.insert(conversationId, userId, assistant, answer, web); return answer; } }这里的关键是ChatMemory.CONVERSATION_ID这个参数它让MessageChatMemoryAdvisor知道该读写哪个会话的记忆窗口。业务表用同一个conversationId关联但查询时走的是biz_chat_history跟 Memory 完全解耦。用户打开页面看历史查biz_chat_history准备调模型走chatMemory。职责分开后面才不会乱。4. 验证请求多轮对话脚本确认记忆读写与历史回放互不干扰配置写完得验证两件事一是 Memory 确实按窗口滑动二是 Chat History 确实完整。写个多轮对话脚本跑一遍。先写一个测试接口方便用 curl 打RestController RequestMapping(/api/chat) public class ChatController { private final ChatService chatService; public ChatController(ChatService chatService) { this.chatService chatService; } PostMapping public MapString, String chat(RequestBody MapString, String req) { String answer chatService.chat( req.get(conversationId), req.get(userId), req.get(question)); return Map.of(answer, answer); } }启动服务后用 curl 连续打 12 轮验证 Memory 窗口。先记住一个事实maxMessages2012 轮问答是 24 条消息超出 4 条最早 2 轮应该被移出记忆窗口。CONVtest-conv-001 for i in $(seq 1 12); do curl -s -X POST http://localhost:8080/api/chat \ -H Content-Type: application/json \ -d {\conversationId\:\$CONV\,\userId\:\u1\,\question\:\第 $i 轮请记住数字 $i\} echo done跑完后第 13 轮问一个依赖早期记忆的问题比如「我第一轮让你记的数字是几」。如果 Memory 窗口正常滑动模型应该答不上来因为第 1 轮已被移出窗口。再问「我第 11 轮让你记的数字是几」应该能答对。这就验证了 Memory 的滑动行为。接着验证 Chat History 完整。直接查业务表SELECT role, content, created_at FROM biz_chat_history WHERE conversation_id test-conv-001 ORDER BY id ASC;预期结果24 条记录12 轮 × 2从第 1 轮到第 12 轮全在一条不少。这就说明历史回放和记忆窗口互不干扰——Memory 里只剩最近 10 轮但业务表里 12 轮完整。再验证一个边界Memory 里到底剩几条。可以临时加个调试接口读chatMemory.get(conversationId)GetMapping(/memory/{conversationId}) public ListString peekMemory(PathVariable String conversationId) { return chatMemory.get(conversationId).stream() .map(m - m.getMessageType() : m.getText()) .toList(); }打GET /api/chat/memory/test-conv-001预期返回 20 条消息最早的是第 3 轮的用户消息。如果返回 24 条说明窗口没生效检查maxMessages是否被覆盖如果返回条数远少于 20检查是不是每轮只存了一条消息。实测下来这套验证跑通后Memory 和 History 的边界就非常清晰了Memory 是给模型看的滑动窗口History 是给业务看的完整档案。两者用同一个conversationId关联但读取路径完全分开。5. 常见报错排查401、local proxy failed、reading choices、OAuth接入和验证过程中几个报错几乎必踩逐个说清楚。401 Unauthorized。最常见Key 没配对或没生效。先确认环境变量真的注入了echo $TAOTOKEN_API_KEYLinux/macOS或echo $env:TAOTOKEN_API_KEYPowerShell。如果为空说明启动进程没继承到。再确认application.yml里写的是${TAOTOKEN_API_KEY}而不是硬编码的空串。还有一种情况是 Key 复制时带了空格或换行重新从 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 复制一次。local proxy failed / Connection refused。这个报错通常跟本机网络环境有关不是 Key 的问题。先确认base-url写的是https://taotoken.net/api没有多余路径。再确认本机没有配置奇怪的全局代理把请求拦走。如果公司网络有出口限制换一个网络环境试。注意不要在任何配置里写代理地址直接连taotoken.net即可。Error reading choices / choices is null。这个报错说明请求发出去了但响应体解析失败。常见原因是model参数填了控制台不支持的模型名。去 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 查可用模型列表把spring.ai.openai.chat.options.model改成列表里的值。另一个原因是 Base URL 多写了/v1导致请求打到错误路径返回了非预期结构。OAuth / authentication 相关报错。如果你用的是 Claude Code 这类工具接入报 OAuth 错误通常是鉴权方式没选对。Claude Code 接入时Base URL 填https://taotoken.net/apiKey 填控制台创建的 KeyModel ID 填对应模型。三件套缺一不可只填 Key 不填 Base URL 会走默认端点自然报鉴权失败。同理Cline 配 MCP、Codex 配auth.json时也是 Base URL Key Model ID 三件套齐全{ baseUrl: https://taotoken.net/api, apiKey: sk-你的Key, model: gpt-4o-mini }排障顺序建议先看 HTTP 状态码401 查 Key404 查 Base URL 路径500 查模型名。把请求体和响应体都打日志比猜快得多。如果还是不通去接入文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 对照示例或者用模型对话页面 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite 先手动发一条消息确认 Key 本身可用再回来查代码。6. 长期编码与 Agent 场景用 Coding Plan 把记忆策略跑稳Memory 和 Chat History 的边界理清后真正难的是长期跑。Agent 的记忆不只是聊天历史还可能包括任务状态、中间结果、工具调用记录。哪些进 Memory哪些进 History哪些进任务状态表需要在项目里定规矩。我的经验是三条线分开模型上下文走ChatMemory业务留痕走biz_chat_history任务状态单独建表。Agent 执行多步任务时中间结果不要一股脑塞进 Memory否则窗口很快被占满真正需要的上下文反而被挤出去。工具调用结果如果对后续推理有用摘要后进 Memory如果只是留痕进 History。如果你要长期做 Spring AI、RAG、Memory、Tool Calling 这类开发频繁调模型验证记忆策略用 Coding Plan 会比按次调用省心。地址是 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 适合需要反复跑多轮对话、验证窗口滑动、调试摘要策略的场景。配合统一的 Base URLhttps://taotoken.net/api和同一个 Key代码里不用改接入层专注调 Memory 策略就行。最后留一个实用技巧给MessageWindowChatMemory的maxMessages设值时先算清楚你的平均轮次长度。如果一轮问答平均 3 条消息用户、助手、工具那maxMessages20实际只覆盖约 6 到 7 轮。别按「20 轮」去估否则上线后又会遇到「历史怎么少了」的问题。窗口大小是策略不是容量按业务实际对话长度调比拍脑袋设一个大数字靠谱。
返回列表