Spring AI对话持久化实战:自定义表结构+Token消耗统计
Spring AI 的对话持久化说难不难说简单也不简单。团队最近在做的那个 AI 助手项目上线不到两周问题清单里排在最前面的不是回答质量而是两个看着不起眼、实际很要命的事用户刷新页面之后对话记录没了后台完全看不到每一次提问到底烧了多少 token。这两个需求合并到一块其实就是标题里那件事——Spring AI 对话持久化自定义数据库结构加上对话元数据token 消耗持久化而且全程不侵入业务代码。如果你也需要把对话记录存进自己的数据库表同时把每次请求消耗的 token 用量也落库统计又不想让这些逻辑把业务代码搅得一团糟这篇文章应该能给你一条可以直接落地的路径。我会从方案选型、表结构设计、核心代码实现、业务侧装配到实际踩过的坑按一条完整链路讲清楚。整个方案我已在真实项目里跑过不是纸上谈兵。1. 需求拆解默认方案到底卡在哪里1.1 默认的对话存储为什么不够用Spring AI 本身是提供了对话持久化能力的最常用的方式是引入spring-ai-starter-bom后配合spring-ai-starter-model-openai这类模型依赖再在配置里打开持久化开关用框架自带的JdbcChatMemory把对话记录存进一张固定的表。这张表是框架启动时自动建好的叫spring_ai_chat_memory结构大致是会话 ID、消息内容、消息类型、时间戳这几列。听起来很省事但实际项目里这种“开箱即用”往往是最难用的地方。我这边接手的需求是对话记录不只存给 AI 功能自己读还要给后台运营看运营同事要在列表里看到用户是谁、问题来自哪个渠道、答案有没有被点赞点踩。这些业务字段默认表里一概没有。如果硬塞要么在框架表上无休止地加列要么另搞一张业务表再去做同步中间的坑可想而知。更重要的是默认的实现把“消息”“元数据”“状态”都揉在了一张表里查询路径和业务侧完全不一致。等到你要按用户、按时间范围、按会话维度做统计分析时会发现索引、字段类型、分区策略都不在你的掌控之内。所以“自定义数据库结构”不是炫技而是真实业务里不得不走的路。1.2 “不侵入业务”到底指什么很多人一听到要改持久化方案本能的反应是那业务代码里是不是要加一堆保存记录、计算 token 的代码这就是标题里“不侵入业务形式”要回答的核心问题。不侵入业务指的是业务代码只需要正常调用 ChatClient 的方法把“当前是哪个会话”这个关键参数告诉框架至于历史消息有没有取出来、取出来怎么拼进提示词、对话结束之后消息往哪张表写、token 用量怎么统计怎么存全部由框架层的东西代劳。具体来说Spring AI 里现成的机制是MessageChatMemoryAdvisor它是一个“顾问”会在真正的模型调用前把历史消息取出来塞进 prompt在模型返回后把这次对话内容写回存储。我们只要自定义一个ChatMemory实现并把它注册到 Spring 容器里MessageChatMemoryAdvisor就会自动用我们的实现去读写数据。业务代码里连注入ChatMemory都不需要。token 元数据这块框架本身没有统一给出一套“自动记录到数据库”的能力所以需要自己动手。我的做法是做一个ResponseAdvisor的实现在模型响应回来、还没交给业务方之前把响应里的TokenUsage捞出来写到另一张独立的元数据表里。业务侧同样感知不到这段逻辑的存在。这两条线串起来正好组成标题里的完整方案对话内容走自定义ChatMemory对话元数据token 消耗走自定义ResponseAdvisor两边都不碰业务代码。2. 数据库结构设计三张表各司其职2.1 表结构拆解既然要自定义结构第一步不是写代码而是先把表设计清楚。我的方案里一共三张表分别承担不同职责彼此之间通过业务主键关联不搞大而全的“万能表”。第一张是会话表。它描述“一次对话”的维度信息会话的 ID、归属的用户 ID、来源渠道、会话标题、创建时间、最后活跃时间。这张表主要给业务方查询会话列表用。第二张是消息表。它存储具体的问答消息消息 ID、会话 ID、消息角色user 或 assistant、消息内容、附件信息、时间戳。这张表是ChatMemory读写的主要对象。第三张是 token 消耗表。它记录每一次模型调用的用量明细会话 ID、请求 ID、模型名称、prompt tokens、completion tokens、total tokens、本次调用的耗时、创建时间。这张表专门给成本核算和监控用。为什么要拆成三张而不是两张因为消息和 token 用量的生命周期不完全一样。消息表是按会话维度频繁读写的而 token 消耗表是按时间维度做统计分析为主两张表混在一起查询性能互相拖累而且字段职责也不清晰。拆开之后每一张表的索引设计都可以做得更精准。2.2 表结构设计中的几个细节这里有几个容易忽略的细节值得展开讲。第一消息表里不要用自增主键去排消息顺序。真实项目里一旦有并发写入或者后期做数据迁移自增 ID 的顺序性并不可靠。建议在表里加一个业务时间字段create_time排序时优先按这个字段再加一个自增 ID 做 tie-breaker避免同一秒多条消息时顺序错乱。第二会话表里的last_active_time字段一定要有。实现get历史消息时很多场景需要按会话维度筛选最近活跃的会话有这个字段就能避免全表扫描。第三token 消耗表里的request_id不要随便用 UUID 字符串建议设计成能对应到某一次模型请求的唯一标识方便以后排查问题时和日志里 traceId 对上。2.3 建表 SQL 示例下面是实际用过的建表脚本字段做了一些脱敏结构保持一致。CREATE TABLE ai_conversation ( id VARCHAR(64) PRIMARY KEY, user_id VARCHAR(64) NOT NULL, title VARCHAR(255), source VARCHAR(32), create_time DATETIME NOT NULL, last_active_time DATETIME NOT NULL, INDEX idx_user_time (user_id, last_active_time) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; CREATE TABLE ai_conversation_message ( id BIGINT AUTO_INCREMENT PRIMARY KEY, conversation_id VARCHAR(64) NOT NULL, role VARCHAR(16) NOT NULL, content TEXT NOT NULL, media_type VARCHAR(32), create_time DATETIME NOT NULL, INDEX idx_conversation_time (conversation_id, create_time, id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; CREATE TABLE ai_conversation_usage ( id BIGINT AUTO_INCREMENT PRIMARY KEY, conversation_id VARCHAR(64) NOT NULL, request_id VARCHAR(64) NOT NULL, model_name VARCHAR(128), prompt_tokens INT, completion_tokens INT, total_tokens INT, duration_ms BIGINT, create_time DATETIME NOT NULL, INDEX idx_conversation_create (conversation_id, create_time), INDEX idx_create_time (create_time) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;会话表与消息表之间是一对多关系消息表与 token 消耗表通过conversation_id关联但严格来说一次对话可能包含多轮模型调用所以 token 消耗表每次模型调用只写一条记录用request_id区分。提示如果你已经有一张业务侧的“用户对话记录表”完全可以把第一张会话表合并进去只要保证ChatMemory读写时能拿到稳定的会话 ID 即可。3. 对话消息持久化自定义 ChatMemory 的实现3.1 先看清 ChatMemory 接口的职责Spring AI 的ChatMemory是一个很薄的数据访问接口核心方法就几个public interface ChatMemory { void add(String conversationId, ChatMessage message); void add(String conversationId, ListChatMessage messages); ListChatMessage get(String conversationId, int lastN); void clear(String conversationId); void clear(String conversationId, int lastN); }MessageChatMemoryAdvisor在模型调用前会调用get取出最近的 N 条历史消息拼进新的 prompt调用完成后会把当前这次 user 消息和 assistant 响应通过add写入存储。也就是说只要实现了这几个方法对话持久化的读写链路就闭环了。需要注意的是ChartMessage里的role字段在 Spring AI 中可能对应UserMessage、AssistantMessage、SystemMessage等类型。落库时要统一转换成字符串读出来时再根据字符串值转换回对应的消息类型。这个转换逻辑写得不好会出现历史消息角色变成未知类型的情况。3.2 基于 JdbcTemplate 实现自定义存储我用的是JdbcTemplate原因是项目本身已经接了 Spring JDBC不需要额外引入 ORM。如果你项目里用的是 MyBatis 或 JPA也可以换成对应的持久层框架核心逻辑都一样。实现类大致是这样public class CustomChatMemory implements ChatMemory { private final JdbcTemplate jdbcTemplate; public CustomChatMemory(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } Override public void add(String conversationId, ChatMessage message) { jdbcTemplate.update( INSERT INTO ai_conversation_message (conversation_id, role, content, media_type, create_time) VALUES (?, ?, ?, ?, ?), conversationId, message.getMessageType().getValue(), message.getText(), null, LocalDateTime.now() ); } Override public void add(String conversationId, ListChatMessage messages) { for (ChatMessage message : messages) { add(conversationId, message); } } Override public ListChatMessage get(String conversationId, int lastN) { ListMapString, Object rows jdbcTemplate.queryForList( SELECT role, content FROM ai_conversation_message WHERE conversation_id ? ORDER BY create_time DESC, id DESC LIMIT ?, conversationId, lastN ); ListChatMessage result new ArrayList(); for (MapString, Object row : rows) { String role row.get(role).toString(); String content row.get(content).toString(); result.add(toChatMessage(role, content)); } Collections.reverse(result); return result; } Override public void clear(String conversationId) { jdbcTemplate.update(DELETE FROM ai_conversation_message WHERE conversation_id ?, conversationId); } Override public void clear(String conversationId, int lastN) { // 实现时需要注意按时间倒序保留最近 N 条之外的数据删除 jdbcTemplate.update( DELETE FROM ai_conversation_message WHERE conversation_id ? AND id NOT IN ( SELECT id FROM (SELECT id FROM ai_conversation_message WHERE conversation_id ? ORDER BY create_time DESC, id DESC LIMIT ?) t), conversationId, conversationId, lastN ); } private ChatMessage toChatMessage(String role, String content) { // 根据 role 字符串创建 UserMessage / AssistantMessage / SystemMessage return switch (role) { case USER - new UserMessage(content); case ASSISTANT - new AssistantMessage(content); case SYSTEM - new SystemMessage(content); default - throw new IllegalArgumentException(未知消息角色: role); }; } }这段代码里有个细节值得说get方法先做倒序查询再反转列表。为什么因为LIMIT lastN配合ORDER BY create_time DESC可以直接拿到“最新的 N 条”然后再反转顺序历史消息就会按从旧到新的正确顺序拼进 prompt。如果直接正序取前 N 条取到的反而是最早的 N 条整个对话就断片了。3.3 注册到 Spring 容器实现类做好之后只需要在配置类里把它声明成 Bean框架会自动替换掉默认的ChatMemoryConfiguration public class ChatMemoryConfig { Bean public ChatMemory chatMemory(JdbcTemplate jdbcTemplate) { return new CustomChatMemory(jdbcTemplate); } }MessageChatMemoryAdvisor构造时会自动注入容器里的ChatMemoryBean。我们不需要对ChatClient的构建逻辑做任何改动。注意如果你的项目之前使用过默认的JdbcChatMemory切换成自定义实现后默认表里的旧数据不会自动迁移。如果线上已有存量数据需要先做一次数据搬迁否则用户会发现自己“历史对话突然消失”。4. token 元数据持久化从响应中捞用量4.1 为什么需要单独做元数据记录对话消息落库之后还有一块硬需求统计每次请求消耗了多少 token。Spring AI 的模型返回结构里确实带有 token 用量也就是ChatResponse里的TokenUsage里面有promptTokens、completionTokens、totalTokens三个值但这些信息默认不会被自动持久化。如果不记录你会面临两个问题一是成本无法核算尤其是上线后每天调用量上来月底账单来了完全对不上是哪个功能烧的钱二是无法做模型调优不同的提示词策略、不同的历史消息长度对 token 消耗影响非常大没有数据你根本不知道该怎么压缩上下文。4.2 用 ResponseAdvisor 拦截并记录我选择的方案是实现 Spring AI 的ResponseAdvisor接口。这个接口会在模型返回ChatResponse之后、结果交还业务层之前被回调正好用来做“元数据采集”这类横切逻辑。public class TokenUsageRecorderAdvisor implements ResponseAdvisor { private final JdbcTemplate jdbcTemplate; public TokenUsageRecorderAdvisor(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } Override public ChatResponse advise(AdvisedRequest request, ChatResponse response) { String conversationId request.adviseContext() .get(Advisors.CHAT_MEMORY_CONVERSATION_ID, String.class); if (conversationId ! null response ! null response.getMetadata() ! null response.getMetadata().getUsage() ! null) { TokenUsage usage response.getMetadata().getUsage(); String model response.getMetadata().getModel(); jdbcTemplate.update( INSERT INTO ai_conversation_usage (conversation_id, request_id, model_name, prompt_tokens, completion_tokens, total_tokens, duration_ms, create_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?), conversationId, produceRequestId(request), model, usage.getPromptTokens(), usage.getCompletionTokens(), usage.getTotalTokens(), System.currentTimeMillis(), LocalDateTime.now() ); } return response; } Override public int getOrder() { return 0; } }AdvisedRequest里可以拿到当前请求的上下文信息其中就包括业务方通过Advisors.CHAT_MEMORY_CONVERSATION_ID传入的会话 ID。这样一来token 记录就和当前会话挂上钩了后续做“某个会话总消耗多少 token”的统计很方便。注意这里有一个关键点ResponseAdvisor是在模型返回之后立即执行但它本身不能修改业务方拿到的ChatResponse。所以就算记录逻辑报错也不会影响业务侧拿到正常的响应结果。如果希望在写入失败时不阻断主流程建议在这个方法内部用 try-catch 包住写入逻辑避免一个统计功能拖垮主业务。4.3 流式调用场景怎么处理上面的方案在同步调用下完全没有问题但如果你用的是流式接口SSE 逐字返回ResponseAdvisor的触发时机和频率就需要注意了。我在实现时发现流式场景下advise方法可能是在完整响应聚合完成后才调用也可能在流的末尾被触发不同 Spring AI 版本行为不太一致。稳妥的做法是在流式调用场景里不要依赖ResponseAdvisor改为手动把ChatResponse流做一次doOnComplete之类的聚合。也就是说在流结束的时候把最后一次响应里的TokenUsage取出来持久化。对于同步调用继续保留ResponseAdvisor方案。两条路并行代码也不复杂。提示如果你们的业务主要是聊天机器人这种需要边生成边展示的场景一定不要只在同步路径上做 token 记录否则你会发现线上大量流式请求的 token 数据是缺失的。5. 不侵入业务的组装与验证5.1 全局装配 Advisor现在把两个自定义组件组装起来让业务侧零感知。我是在ChatClient构建的时候通过defaultAdvisors把两个 Advisor 同时放进去Configuration public class ChatConfig { Bean public ChatClient chatClient(ChatClient.Builder builder, ChatMemory customChatMemory, JdbcTemplate jdbcTemplate) { return builder .defaultAdvisors(new MessageChatMemoryAdvisor(customChatMemory)) .defaultAdvisors(new TokenUsageRecorderAdvisor(jdbcTemplate)) .build(); } }MessageChatMemoryAdvisor负责对话历史的自动读写TokenUsageRecorderAdvisor负责 token 用量的自动落库。业务代码在拿到ChatClient之后不需要关心任何持久化细节。5.2 业务侧调用长什么样业务侧的调用代码基本上和原本没有任何区别public String chatWithUser(String conversationId, String userMessage) { return chatClient.prompt() .user(userMessage) .advisors(a - a.param(Advisors.CHAT_MEMORY_CONVERSATION_ID, conversationId)) .call() .content(); }注意那一行advisors的调用。这一行不是可选的它是整个“不侵入业务”方案的开关只要把conversationId传进上下文框架就会自动去取历史消息、自动把新消息写入存储、自动记录 token。业务函数本身只有“传话、发问、拿结果”三个动作。如果你的业务是 Web 层可以在进入 Controller 时从请求头或登录态里解析出用户 ID再和前端传入的会话 ID 拼成一个稳定 ID 传入上下文中比如userId : conversationId。这样即使不同用户传了相同会话 ID数据也不会互相串。5.3 验证效果跑一次完整对话代码装好后最好做一次全链路验证。我当时的验证步骤是新开一个会话发送第一条消息“你好”确认数据库里多了一条 role 为 USER 的消息和一条 role 为 ASSISTANT 的消息。不传 conversationId 再次调用确认没有新增任何内容说明历史没有被捞起来。传回同一个 conversationId继续发送“刚才我说了什么”确认 AI 能根据历史回答说明get方法生效。查看ai_conversation_usage表确认每次调用都多了一条 token 用量记录。这四步跑通整个方案就算闭环了。之后再去补充会话列表接口、成本统计接口都会非常顺利因为这些表结构就是你自己的想怎么查就怎么查。6. 常见问题与避坑实录6.1 对话历史顺序错乱这是我刚开始实现get方法时踩过的一个坑。当时先按create_time ASC取前 N 条结果新对话刚发了一条消息历史里取出来的是最早的一批AI 完全“失忆”。原因前面已经说过LIMIT搭配正序排序取到的是最早的 N 条。后来改成“倒序取 N 条再反转顺序”问题立刻消失。如果你们并发量高、同一会话的消息可能在同一个毫秒内写入建议在排序条件里像我的 SQL 一样加上id DESC作为第二排序键。否则同秒内多条消息的顺序可能随机。6.2 token 用量统计为 0 或缺失遇到统计为 0 的情况不要急着怀疑代码先确认模型商的返回里有没有带用量信息。有些模型或某些降级场景下TokenUsage会是 null此时代码里要做空判断否则插入语句会因为 NPE 直接失败。我的建议是空值也照常落库但写 0 或者 NULL后续统计时再做过滤这样至少能知道哪些渠道返回的数据是不完整的。还有一点流式调用下 token 统计缺失的问题很隐蔽。如果你的ChatClient用的是stream()方法建议在流式响应处理完毕后单独从最后一次ChatResponse里取 usage 落库不要写在每个 chunk 的回调里不然一次完整回答可能会被记录成多笔小金额。6.3 历史消息表越积越厚对话场景的表增长速度非常快。用户每发一句、AI 每回一句就是两行数据。一次长对话几十轮下来消息表很快积压。如果不做处理三个月后消息表可能上千万行。我的建议是分两步走。第一步在消息表上把conversation_id、create_time、id做成联合索引这是查询性能的基本保障。第二步在业务上做策略默认只保留最近 30 天或最近 100 轮对话更老的数据归档到冷表。归档任务可以用定时任务实现在低峰期执行。6.4 框架版本升级带来的兼容性Spring AI 还处于快速演进阶段ChatMemory、ResponseAdvisor、Advisors这些 API 在不同小版本之间出现过签名变化。最典型的是ChatResponse里拿TokenUsage的路径老版本是response.getResult().getUsage()新版本可能变成response.getMetadata().getUsage()。我的经验是先在测试环境把项目依赖升到目标版本跑一遍第 5 节里的四步验证确认消息读写和 token 记录都正常再上生产。不要在升版本之后默认“接口没变”这种乐观估计我在真实项目里已经吃亏过了。6.5 多实例部署下的重复写入如果你的应用是多个实例部署ResponseAdvisor里的写入逻辑理论上可能因为重试机制被触发多次。我在实现时没有引入分布式锁而是在ai_conversation_usage表里给request_id加了唯一索引插入的时候用INSERT IGNORE或者“先查再插”的方式做幂等。这样就算同一个请求被重复执行也不会产生多条重复的 token 记录。类似地消息写入如果出现重复可以用消息表里“会话 ID 消息唯一标识”的联合唯一索引来兜底。当然这要求业务侧在每次发送消息时能生成一个稳定的消息 ID 传进来。7. 最后分享一点实际项目中的体会这套方案做下来最大的收益不是“能存对话了”而是“业务代码从头到尾没有变脏”。我见过很多项目为了记录 token 把统计逻辑写进 service 层的每一个方法里后来需求一改、模型一换统计口径崩得一塌糊涂。用 Advisor 机制把持久化收敛到框架层后面加字段、改表结构、换存储都不需要惊动调用方。如果你后续想更进一步可以考虑把消息表和 token 消耗表的数据同步到数据仓库里做更细粒度的成本分析和提示词效果分析。这是我下一阶段正在做的事。希望这篇基于真实实践整理的方案能帮你少走些弯路尤其是表结构设计和流式调用那两个坑提前避掉能省不少时间。