做 AI 应用的朋友都知道多轮对话是刚需但 LLM 本身并不“记得”你上一轮说了什么。Spring AI 里负责解决这个问题的组件叫 ChatMemory它管着对话历史在内存中的存取也决定了能不能把历史落到数据库或 Redis 里长期保留。这篇文章按我在真实项目里改造多轮对话的完整过程用三步把 ChatMemory 从默认的内存实现一路升级到持久化方案过程中会拆解核心接口、Message 类型、上下文窗口策略以及我在生产环境里踩过的几个坑。适合第一次用 Spring AI、被“AI 老忘记上下文”搞到头大的同学也适合已经在项目中集成了 ChatClient、正打算把对话历史从纯内存换成数据库或 Redis 的开发者。我尽量不堆概念直接按实操讲每一步都是能在本地跑起来、能直接抄进项目的代码。看完你就知道多轮对话背后发生了什么以及该怎么选存储、怎么改代码。1. 为什么多轮对话离不开 ChatMemory先理解原理再动手1.1 LLM 的“失忆症”与 ChatMemory 的职责LLM 本质是一个无状态函数你传一段 prompt 进去它吐一段文本出来。上一轮聊天内容它并不知晓。要让 AI“记得”用户几分钟前说过的话必须由我们自己在每次调用时把历史对话拼进上下文。这个把历史“存起来、取出来、拼进去、再把新对话补回去”的闭环就是 ChatMemory 的职责。一次标准的多轮对话流程大致是这样用户发消息。从 ChatMemory 按 conversationId 取最近 N 条历史。组合成含历史的 prompt 发给模型。模型返回回答。把用户消息和 AI 回复写回 ChatMemory。你可以把 ChatMemory 想象成会议纪要员每次开会前它把上次会议的纪要及时放到你桌上散会后它又把新内容归档。没有纪要员每次开会都是从零开始会议质量自然差。AI 对话也是一样没有记忆机制再厉害的模型也接不上上下文。顺带说一句如果你做的是 RAG 问答ChatMemory 仍然负责对话历史的存取但要注意检索出来的文档片段不应该塞进 ChatMemory。它们是“知识上下文”不是“对话上下文”混在一起会带来两个问题——对话窗口很容易被撑爆token 消耗翻倍而且对话历史的控制逻辑和检索结果的控制逻辑互相干扰后面调优很痛苦。我见过把检索结果也存进 ChatMemory 的设计窗口设得不大还好设大了响应速度和成本都很难看。1.2 ChatMemory 在 Spring AI 中的位置从接口到服务的调用链Spring AI 把 ChatMemory 抽象成了一个接口核心方法就四个public interface ChatMemory { void add(String conversationId, ListMessage messages); // 追加消息 ListMessage get(String conversationId, int lastN); // 取最近 N 条 void clear(String conversationId); // 清空会话 void delete(String conversationId); // 删除会话 }任何一个方法都要传 conversationId这是多轮对话的“会话句柄”所有历史都挂在它下面。设计成接口的好处是你只管定义“存哪里、怎么存”具体的存储实现可以随时替换内存、MySQL、Redis只需要换一个实现类业务代码基本不用动。实际项目中你不一定需要直接调用 ChatMemory。Spring AI 提供了 Advisor 机制其中MessageChatMemoryAdvisor会在 ChatClient 执行 prompt 之前自动把历史注入执行之后自动把新消息写回。我们只需要在构造 ChatClient 时把 ChatMemory 绑定到 Advisor 上。调用链大致是用户请求 → ChatClient.prompt().user(...) → MessageChatMemoryAdvisor 读取 ChatMemory 中的历史 → 组装最终 prompt → 调用模型 → 返回后 Advisor 把新消息写回 ChatMemory这个设计把“记忆管理”封装在框架内部业务代码只需要维护好 conversationId 就行。这也是我建议新手先理解调用链再写代码的原因——否则你会在“明明配置了 ChatMemory为什么还是记不住”上绕很久。版本升级也不用怕Spring AI 2.0 对 ChatMemory 的核心接口保持了兼容变化主要在 starter 依赖拆分和自动配置的包名调整上遇到编译错误优先查官方迁移文档别急着改接口。2. 方案选型内存、Redis、还是数据库持久化2.1 三种存储方案怎么选先看这两个维度做方案选型时我一般只看两个维度这个场景对持久性的要求有多高以及服务是不是多实例部署。这两个维度基本能确定方案方向下面的对照表是我常用的一张速查方案存储位置持久性并发/分布式适用场景维护成本InMemoryChatMemoryJVM 堆内进程一停就没了仅限单实例本地联调、MVP、单机小规模最低JDBC/MyBatis 自定义实现MySQL/PostgreSQL长期持久多实例共享一份数据生产业务、需要查询历史、审计中Redis 实现Redis依赖 RDB/AOF 持久化配置多实例共享天然支持 TTL生产业务、会话频繁读写、有自动淘汰需求中高内存版不是不能用。小型内部工具、原型验证、单实例部署的场景下InMemoryChatMemory完全够用配置最简单出问题也最好排查。但只要你面临三个问题中的任何一个——服务重启、多实例部署、会话数据量持续增长——就必须考虑持久化。JDBC 方案适合“我要确保数据不丢、以后还要查询分析”的场景。比如客服系统需要拉出某个用户的历史对话记录做质检数据库天然适合。Redis 方案适合“我要高吞吐、会话数据量大、希望自动过期”的场景。比如开放平台给外部客户调用对话接口一天几百万次会话每条历史还占内存用 Redis 的 TTL 自动淘汰最省心。2.2 踩坑实录为什么不要一上来就上 Redis有一个很典型的反面教材项目刚启动还在原型阶段就把 Redis 接进来——会话用 Redis、验证码用 Redis、ChatMemory 也用 Redis。上线当天 Redis 还没出问题反而因为多了一层网络调用调试接口时超级难受缓存击穿、序列化异常全来了。后来我们复盘得出一个原则第一步永远先用 InMemoryChatMemory 把对话流程和 UI 打通。等确认功能逻辑没问题再评估规模——日活多少、会话频次多少、是否需要多实例部署。如果只是怕重启丢数据直接换 JDBC 就够了如果有多实例共享会话或会话量大、需要 TTL再上 Redis。千万别为了“看起来专业”而过度设计。还有一点容易被忽略内存版 ChatMemory 如果运行时间长、会话数多、窗口设得大JVM 堆里会累积大量历史消息。有一次压测我看到内存曲线一路往上走GC 频繁甚至 Full GC 后内存仍回不到基线。这时候除了排查代码里是否有内存泄漏更要重点检查 ChatMemory 是不是无界增长。InMemoryChatMemory的内部是一个ConcurrentHashMapString, ListMessage只要不主动 clear会话历史就一直留着一天几万条对话下来堆内存被吃掉几百 MB 很轻松。所以如果决定继续用内存方案至少得加一个定时任务清理过期会话。3. 三步实操从内存到持久化的完整落地3.1 第一步用 InMemoryChatMemory 跑通单机多轮对话先说依赖。以 OpenAI 模型为例Maven 引入dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency注意 Spring Boot 版本要和 Spring AI 版本匹配Spring Boot 3.3.x 配 Spring AI 1.0.0 系列比较稳。网上经常有人搜“spring ai maven 智谱ai version”如果你用智谱、通义这类国产模型starter 换成对应的spring-ai-starter-model-zhipuai、spring-ai-starter-model-tongyi即可底层 ChatMemory 部分完全一样。版本号别直接抄旧博客里的硬编码去官方 BOM 里查当前可用的版本。然后写配置类Configuration public class ChatConfig { Bean public ChatMemory chatMemory() { return new InMemoryChatMemory(); } Bean public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) { return builder .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory, 10)) .build(); } }MessageChatMemoryAdvisor的第二个参数10是窗口大小意思是每次调用时只带最近 10 条消息大约 5 轮对话。为什么给 10因为在绝大多数客服、助手场景里10 条足够模型理解上下文同时 token 成本可控。这个值不建议拍脑袋设大后面第 4 章会细讲。接下来写一个简单的 ServiceService public class ChatService { private final ChatClient chatClient; public ChatService(ChatClient chatClient) { this.chatClient chatClient; } public String chat(String conversationId, String userMessage) { return chatClient.prompt() .user(userMessage) .advisors(a - a.param(ChatMemory.CONVERSATION_ID, conversationId)) .call() .content(); } }注意这里一定要通过 advisors 传入conversationId参数名是conversationId类名或包名因 Spring AI 版本略有差异但语义一致。很多人漏掉这一步导致每次调用都新生成一个会话 ID历史根本对不上——这是“为什么我配了 ChatMemory 还是记不住”最常见的根因。测试时用同一个conversationId连续调用两次// 第一次调用 chatService.chat(1001, 你好我叫张三); // 第二次调用 chatService.chat(1001, 我叫什么名字);第二次应该回答“张三”。如果回答不出来十有八九是 conversationId 没传或者 Advisor 没生效先查这两个点。3.2 第二步自定义持久化 ChatMemory把对话存进数据库内存版跑通之后接下来要让历史“重启不丢”。这一节我带你实现一个基于 Spring JDBC 的ChatMemory。你也可以用 JPA/MyBatis思路完全一样只是存储层技术栈不同。先建表CREATE TABLE chat_message ( id BIGINT AUTO_INCREMENT PRIMARY KEY, conversation_id VARCHAR(64) NOT NULL, message_type VARCHAR(16) NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_conversation_time (conversation_id, id) );message_type存USER、ASSISTANT、SYSTEM、TOOL这类枚举名content存消息文本。然后实现接口Component public class JdbcChatMemory implements ChatMemory { private final JdbcTemplate jdbcTemplate; public JdbcChatMemory(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } Override public void add(String conversationId, ListMessage messages) { for (Message message : messages) { jdbcTemplate.update( INSERT INTO chat_message (conversation_id, message_type, content) VALUES (?, ?, ?), conversationId, message.getMessageType().name(), message.getText() ); } } Override public ListMessage get(String conversationId, int lastN) { ListMessage messages jdbcTemplate.query( SELECT message_type, content FROM ( SELECT id, message_type, content FROM chat_message WHERE conversation_id ? ORDER BY id DESC LIMIT ? ) t ORDER BY t.id ASC, (rs, rowNum) - { MessageType type MessageType.valueOf(rs.getString(message_type)); String content rs.getString(content); return switch (type) { case USER - new UserMessage(content); case ASSISTANT - new AssistantMessage(content); case SYSTEM - new SystemMessage(content); default - throw new IllegalStateException(Unexpected type: type); }; }, conversationId, lastN ); return messages; } Override public void clear(String conversationId) { jdbcTemplate.update(DELETE FROM chat_message WHERE conversation_id ?, conversationId); } Override public void delete(String conversationId) { clear(conversationId); } }get方法里为什么套一层子查询因为直接ORDER BY id DESC LIMIT ?拿到的是“最新 N 条”但顺序是倒的必须再按 id 正序排列一次这样拼进 prompt 时消息顺序才正确。另外用id而不是created_at排序是有原因的自增主键天然代表写入顺序created_at在高并发下可能重复用时间排序容易乱。然后把 3.1 里的 Bean 换成JdbcChatMemoryBean public ChatMemory chatMemory(JdbcTemplate jdbcTemplate) { return new JdbcChatMemory(jdbcTemplate); }如果你用的 Spring AI 版本比较新1.0.0 之后的某个版本可以留意官方提供的JdbcChatMemory代码更完善。但我建议你还是自己实现一遍理解更透后面遇到消息转换、顺序、分页问题都不会慌。真实场景里还有一个细节Message接口除了 text 字段还有 metadata、media 等。如果用户发的是图片消息或带文件的消息纯存取 text 就不够了。通用的做法是用ObjectMapper把 Message 整体序列化成 JSON存到一个message_json字段读取时用 Spring AI 提供的JsonMessageToMessageConverter转回具体类型。我在项目里存的就是message_json字段比拆字段省心很多。MVP 阶段先存文本完全够用等到需要处理图片、工具调用时再升级即可。3.3 第三步接入 Redis 实现分布式会话记忆当服务变成多实例部署或者会话数量大到需要自动过期清理Redis 是更顺手的方案。Spring AI 官方有过 RedisChatMemory 的实现但不同版本 API 差异较大我更推荐自己用 Spring Boot 的StringRedisTemplate包一层ChatMemory接口逻辑清晰、可控性强。先看 key 设计key 格式chat:memory:{conversationId}value 用 Redis List 存消息序列化后的 JSON每次 add 时rightPush一条 JSON同时刷新 TTL比如 24 小时无活跃自动清理读取时range(key, -lastN, -1)拿到最后 N 条消息代码Component public class RedisChatMemory implements ChatMemory { private static final String KEY_PREFIX chat:memory:; private static final Duration TTL Duration.ofHours(24); private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; public RedisChatMemory(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) { this.redisTemplate redisTemplate; this.objectMapper objectMapper; } Override public void add(String conversationId, ListMessage messages) { String key KEY_PREFIX conversationId; for (Message message : messages) { redisTemplate.opsForList().rightPush(key, serialize(message)); } redisTemplate.expire(key, TTL); } Override public ListMessage get(String conversationId, int lastN) { String key KEY_PREFIX conversationId; ListString jsonList redisTemplate.opsForList().range(key, -lastN, -1); if (jsonList null || jsonList.isEmpty()) { return List.of(); } return jsonList.stream().map(this::deserialize).toList(); } Override public void clear(String conversationId) { redisTemplate.delete(KEY_PREFIX conversationId); } Override public void delete(String conversationId) { clear(conversationId); } private String serialize(Message message) { try { return objectMapper.writeValueAsString(new MessageRecord( message.getMessageType().name(), message.getText() )); } catch (JsonProcessingException e) { throw new RuntimeException(序列化消息失败, e); } } private Message deserialize(String json) { try { MessageRecord record objectMapper.readValue(json, MessageRecord.class); return switch (MessageType.valueOf(record.type())) { case USER - new UserMessage(record.content()); case ASSISTANT - new AssistantMessage(record.content()); case SYSTEM - new SystemMessage(record.content()); default - throw new IllegalStateException(Unexpected type: record.type()); }; } catch (JsonProcessingException e) { throw new RuntimeException(反序列化消息失败, e); } } record MessageRecord(String type, String content) {} }这里有几个非常容易踩的坑key 别带空格和特殊字符conversationId建议用 UUID 或业务 ID别拿用户输入的原始字符串直接拼 key否则可能出现非法字符。range(key, -lastN, -1)的负索引表示从尾部开始-1 是列表最后一条。第一次用很容易传成正数或把方向搞反建议先写单元测试验证。选StringRedisTemplate而不是RedisTemplateString, Object的原因前者存的是字符串不会有 JDK 序列化带来的二进制兼容问题。如果用后者加 JDK 序列化存进去的是二进制跨版本、跨语言都可能出问题。我在生产环境里吃过这个亏后来统一换成了 JSON 字符串。部署多实例时只要所有实例连同一个 Redis 就行。A 实例写入B 实例读取都能拿到同一份对话历史。这比内存版跨实例完全隔离要好得多。当然前提是 Redis 本身配置了持久化RDB/AOF 至少开一个否则 Redis 重启同样会丢历史。本地没有 Redis 的话可以用 Docker 起一个测试实例docker run -d --name chat-redis -p 6379:6379 --restartalways redis:7-alpine然后配置spring.data.redis.host127.0.0.1即可。加--restartalways很有必要不然开发机重启后 Redis 不会自己起来接口会全挂。4. 核心细节解析ChatMemory 接口、Message 类型与过期策略4.1 ChatMemory 接口的四个方法add、get、clear、delete四个方法里add和get是高频方法clear和delete很容易被忽略但它们才是控制内存和存储膨胀的关键。add(conversationId, ListMessage)把一条或多条消息追加到指定会话。为什么参数是ListMessage而不是单条消息因为 Advisor 在每次交互结束后要同时保存用户消息和 AI 回复有时还有工具调用结果一次批量写入更高效。get(conversationId, lastN)取最近 N 条。注意不是“取全部”而是“最近 N 条”。这直接控制了 prompt 的大小避免所有历史无限叠加上去。clear(conversationId)清空整个会话。用户点了“新建对话”或“清空上下文”时调用。delete(conversationId)删除会话。和clear的区别在不同实现里可能不同但大多数场景下delete等于clear加释放资源。Spring AI 在 1.0.0 之后才给接口加了delete的 default 方法默认调clear。什么时候该调clear一个常见场景前端“新对话”按钮对应后端应该执行clear(conversationId)而不是新建一个 conversationId。新建也行但旧数据会一直留在库里积少成多。尤其用 InMemory 时不清就会一直占着堆内存——这是很隐蔽的内存泄漏来源之一。4.2 Message 类型与 MessageType 枚举别把 SYSTEM 消息漏了Spring AI 里所有对话消息都是Message接口的实现类常见的有UserMessage用户输入AssistantMessage模型回复可能带 toolCallsSystemMessage系统提示词ToolResponseMessage工具调用返回结果存储消息时不能只存文本还必须存消息类型。因为从存储里取回来后必须还原成对应的Message子类才能塞回 prompt。上面 JDBC 和 Redis 示例里的message_type字段就是干这个的。序列化时最容易被忽略的是SYSTEM消息。有些团队在存取历史时只处理USER和ASSISTANT遇到SYSTEM消息直接丢弃。短期看没什么问题但如果你需要在某个会话中途动态调整系统提示词历史里的 SYSTEM 消息就很重要——丢掉之后模型在后续轮次里可能会明显“变笨”因为它已经忘了最初的指令边界。如果消息里有图片、附件或 toolCalls纯文本字段会丢失信息。这时候要用 Spring AI 提供的JsonMessageToMessageConverter把完整 Message 对象序列化成 JSON 存起来读取时再转回来。符合通用持久化需求省去自己写一堆字段映射。4.3 上下文窗口与过期策略token 省着用为什么窗口大小不能无限大三个原因Token 成本模型按 token 计费历史越长每次调用越贵而且是乘法增长——每次都要把所有历史重新发一遍。上下文窗口上限模型有 max tokens 限制历史太长可能直接超限报错。注意力质量大量旧信息会稀释模型对最新指令的注意力反而影响回答质量。你面试一个候选人前面 100 轮闲聊的问题都拿来问他反而抓不住你最后一句话的重点。AI 也是这个道理带最近几轮就够了。过期策略建议同时考虑两个维度条数维度通过 windowSize 控制每次取回多少条。时间维度Redis 的 TTL数据库里的 created_at 定期清理任务。多轮对话还有一个容易被忽视的点内存带宽。当消息量很大、窗口很长时把历史从存储里捞出来、序列化进 prompt会消耗内存和 CPU 时间。我实测过窗口从 10 调到 50响应时延不会线性增长但内存和 token 消耗会明显上去。所以“刚好够用”比“越多越好”更合理。RAG 场景再补充一句如果你做 RAG对话历史里不要塞文档内容文档内容放到向量检索结果里作为“参考资料”单独注入 prompt。ChatMemory 只管“对话上下文”vector store 管“知识上下文”两条线分开后面调优才清晰。5. 常见问题与排查技巧实录5.1 问题一对话“记不住”前面的内容症状第二次换一个问题问“我叫什么”模型完全不知道。排查清单是否通过 advisors 传了conversationId没传的话每次都是新 UUID等于每次都是新会话。是否在配置类里把MessageChatMemoryAdvisor加到了defaultAdvisors只 new ChatClient 不配 Advisor 是不行的。windowSize 是否太小如果设成 1第二次调用时只带了一轮 AI 的回答自然不知道你叫什么。是否在每次调用前执行了clear有人为了避免历史膨胀在 service 里手动 clear结果历史根本没积累。如果是多实例部署且用的是内存版A 实例接收的会话B 实例没有那一段历史也会“记不住”。第 5 点最容易误判。排查时先看日志里请求落在哪个实例如果是两台实例轮流接直接换 Redis 或 JDBC 方案。5.2 问题二持久化了但服务重启后丢失症状数据库里明明有数据重启后 AI 还是失忆。原因通常是配置类里注册的 ChatMemory Bean 还是InMemoryChatMemory只是把数据源配置好了没有把 Bean 替换成 Jdbc 版本。代码写了两套实现结果Primary没生效。另一类原因自定义实现里连接的是本地内存数据库比如 H2重启即清空而日志里看不出连接的是哪个库。我见过一次项目把spring.datasource.url配到了本地 test 库生产环境没配置过去上线后数据全写进了临时库。排查技巧确认 ChatMemory Bean 的实际类型启动日志里打印一下 Bean 类名或者断点看。确认数据源 URLcurl http://localhost:8080/actuator/env | grep datasource或者看连接池日志。5.3 问题三并发环境下会话串号症状用户 A 问的问题在用户 B 的回答里出现了上下文串扰。原因几乎都是 conversationId 设计问题。常见错误有前端传了什么就用什么有的客户端把 userMessage 本身当 conversationId内容一变 ID 就变。用 userId 当 conversationId不同会话之间互相污染。直接用UUID.randomUUID().toString()在每次请求时生成等于一个请求一个会话回到了问题一。正确做法是会话创建时由服务端生成一个稳定 ID返回给前端保存后续请求带上这个 ID。如果同一会话有并发请求最好在会话维度加锁或用 Redis 事务保证读写一致性避免“后写先读”导致历史顺序错乱。我在高并发压测时遇到过同会话两条请求互相覆盖的问题加了个会话维度锁之后就好了。5.4 问题四内存占用持续上涨怎么排查聊到多轮对话就绕不开内存话题。我遇到的内存上涨主要有三类堆内对象膨胀InMemoryChatMemory的会话历史无界增长最典型。堆外内存如果用到向量检索、本地 embedding 模型推理比如 ONNXNative 内存可能增长。Spring AI 本身不主动引入堆外问题但这类组件要留意。GC 参数不合理JVM 堆设得太大或太小都会出问题。排查步骤先看 GC 情况jstat -gcutil pid 1000 10如果 Full GC 频繁且堆回收后依然高dump 一份堆jmap -dump:live,formatb,fileheap.bin pid拿 MAT 打开重点看ConcurrentHashMap里是不是堆了大量 ChatMessage 对象。如果堆内正常但进程 RSS 很高用 NMT 查堆外jcmd pid VM.native_memory summary注意 NMT 需要启动时加-XX:NativeMemoryTrackingsummary没加的话要重启才能查。解决策略给 ChatMemory 加会话数上限定时清理无效会话。windowSize 设小一点。尽早从InMemoryChatMemory切到 Redis 或 JDBC。Redis 版本用 TTL 自动淘汰从根上解决“历史无界增长”。5.5 排查清单速查表现象可能原因首要排查动作多实例下记不住内存版无法共享查看请求落在哪个实例重启后丢历史依然是 InMemory Bean打印 ChatMemory 实际类型对话串号conversationId 生成错误检查前端传参与会话创建逻辑内存持续上涨历史无界增长jmap MAT 看 HashMap 对象prompt 超限windowSize 过大看请求日志里 token 数Redis 重启后丢历史RDB/AOF 未开启检查 Redis 持久化配置最后分享一个我自己的体会ChatMemory 的改造一定要跟着场景走。小工具用内存版没毛病生产环境要稳就得上持久化但没有必要一开始就把 Redis、MySQL 全堆上。我们项目最终采用的是“JDBC 持久化 定期清理 前端新对话触发 clear”的组合数据量上去之后再加 Redis 缓存层。整个过程回头看最有价值的不是某一段代码而是对“历史到底放在哪、取多少条、什么时候清”这三个问题的回答。每个团队的条件和场景不一样先跑通内存版再渐进式升级才是成本最低也最不容易翻车的路线。
企业数字化 ERP 产品动态
相关推荐
如何考察北京地区生产厂家的彩箱与瓦楞纸箱资质 在北京地区筛选瓦楞纸箱与彩箱生产厂家时,核心在于考察其生产体系的完整性、定制响应的灵活性以及质检标准的稳定性。具备从设计到生产一站式服务能力的企业,通常能针对不同预算提供平衡质感与成本的方案,而非单纯依赖低价竞争或仅承接超大订… · 2026/9/26 6:14:58
ARDM深度解析:Redis可视化客户端的协议感知与生产级设计 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 6:14:52
STM32CubeProgrammer 烧录全攻略:ST-Link、串口与USB下载实战 STM32 开发这几年,工具链的变化其实挺大的。早些年大家烧程序基本就是 Keil MDK 里点一下 Download 按钮,或者用 J-Link 的 J-Flash 单独操作,再老一点用 ST-Link Utility。后来 ST 官方把 ST-Link Utility 停更了,全面转向STM32C… · 2026/9/26 6:14:52
windows下git使用教程1(安装与使用) git版本:2.53.0.2
1.什么是git
Git 是一款开源的分布式版本控制系统,由 Linus Torvalds 于 2005 年开发,核心作用是追踪文件(尤其是代码)的修改历史、管理多人协作开发流程,确保代码版本可追溯、可回滚&a… · 2026/9/26 7:58:07
金融科技落地实践:支付系统、反欺诈与监管合规架构设计 三年前我第一次进金融项目现场的时候,甲方问我的第一句话是:“你的方案能不能保证每一分钱都对得上?”我当时觉得这是个简单问题,后来才知道,这是金融服务行业所有技术决策的起点。这些年我一直在做金融服务相关系统的… · 2026/9/26 7:58:07
Ince-Gaussian光束生成涡旋阵列:VirtualLab Fusion仿真全解析 之前一直在VirtualLab Fusion里折腾结构光束仿真,总想着用现成的拉盖尔-高斯或厄米-高斯模式拼出涡旋阵列,结果不是对称性不理想,就是阵列排布太“正”,调参调到怀疑人生。后来换到Ince-Gaussian这一类解系,才意识到自… · 2026/9/26 7:58:01
Jev模型API接入与SDK集成实战:类型安全结构化输出测评 1. 这个模型到底是个什么东西Jev 模型最近在技术社区里刷屏刷得厉害,我身边好几个做 AI 应用的朋友都在群里问“这玩意儿到底怎么接”“跟其他模型比强在哪”。我花了大概三天时间,从官网文档到实际 API 调用,再到 SDK 集成,完整跑… · 2026/9/26 7:58:01
基于UniApp与Spring Boot的微信小程序问卷系统设计与实践 1. 项目背景与技术选型1.1 为什么会做一套小程序问卷系统去年接了一个企业内部的满意度调研需求,原本对方想用现成的第三方问卷平台,但聊下来发现几个问题:一是内部数据不能走外部服务,二是问卷题型比较特殊,需要嵌套逻… · 2026/9/26 7:58:01
数据库课后习题答案别硬背:当测试用例集刷,效率翻倍 简介:万常选版《数据库原理与设计》课后习题答案资源,覆盖第2至6章及第9章,适合正在学习关系模型、数据库建模、关系数据理论与模式求精的本科生、自学者作为复习与自测材料。压缩包共7个文件,含3个doc参考答案、2个sql示例脚本、… · 2026/9/26 0:00:21
OpenClaw 替代品?Hermes Agent 踩坑实录:macOS 飞书接入 TaoToken 配置 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 0:00:40
向下兼容与向上兼容:接口设计中的兼容性策略与工程实践 一次版本升级事故,是很多团队绕不过去的坎。线上环境里,服务端明明已经上线了新版接口,老的移动端还在照着旧文档传参数。请求一到网关,校验直接拒绝,用户操作失败,客服群炸了锅,开发群里开始互… · 2026/9/26 0:00:46