TinyRobot Kit 这个项目名听起来像是给 AI 对话加了一个积木式底座,但它真正要做的事,是把"消息怎么流"和"会话怎么管"这两件最容易被忽视、又最容易翻车的事,在数据层一次性理顺。
我在做 AI 应用落地的过程中,接触过不少对话类产品,无论是给企业做知识库问答,还是给硬件设备做陪聊 Agent,凡是从原型往生产环境走,几乎都会撞上同一堵墙:接口返回是流式的,聊天框是一个的,但用户是多个的、会话是多开的、消息是要对得上号的。大部分时候,大家会把精力放在提示词工程和模型选型上,等到联调时才发现,消息拼接错了、会话切换后上下文串了、刷新页面后历史记录乱套了。这些问题的根源,基本都指向数据层的设计——也就是 TinyRobot Kit 这个名字背后真正想解决的事。
这篇文章会完全围绕对话数据层的组织方式来展开。我会从消息模型、流式增量协议、会话管理、持久化设计到排查技巧,把 TinyRobot Kit 的做法一条条拆开讲,结合我在真实项目中踩过的坑和验证过的方案。如果你是做 AI 应用开发、智能体接入、聊天机器人产品,或者准备给现有系统加 AI 对话框,这篇内容应该能帮你省掉不少试错时间。
1. 对话数据层:AI 应用里最容易被低估的一层
1.1 为什么数据层会决定 AI 对话体验的上限
先坦白一个现状。前几年我帮客户做智能客服系统时,对方需求文档里写满了"意图识别准确率不低于90%""回答必须专业"这类对模型能力的要求,但几乎没有人提过"用户每说一句话,这条消息要怎么在系统里流转"。等到开发进入后端联调阶段,第一位测试同学刚发了三条消息,我们就发现了一个致命问题:模型还在流式输出的时候,第二条用户消息已经到了,后端不知道这段输出到底该归属哪一次提问,前端也不知道怎么把增量内容拼到正确的气泡里。
这就是数据层没有提前设计的典型症状。你可以把 AI 对话系统想象成一家餐厅,模型是后厨的大厨,提示词是菜单,但如果没有一套可靠的传菜系统和账本,大厨炒得再快,菜也会送错桌、账也会记混。数据层就是那套传菜系统和账本。流式消息要求我们能在网络抖动、半包、乱序的情况下,把一个完整的回答还原出来;多会话要求我们能同时维护几十个甚至上千个独立的上下文,互不串味。这两件事都必须在数据层得到系统性解决,而不是靠前端事件回调里打补丁。
1.2 TinyRobot Kit 的思路:先定数据边界,再谈功能实现
TinyRobot Kit 给我的感觉,很像一个愿意在前期做数据建模的工程团队留下的产物。它没有把"AI 对话"当成一个单纯的接口调用,而是先定义了三个核心边界:消息边界、会话边界、资源边界。
消息边界解决的是"一段对话里最小可存储、可追溯、可重放的单元是什么"。在多模态场景下,一条消息可能包含文本、图片、工具调用结果、引用来源,甚至是一个状态变更事件。如果数据层只支持存纯文本字符串,那后面想做引用溯源、工具调用链回放、多模态检索,都得把表结构推翻重来。TinyRobot Kit 的处理方式是让消息成为一个结构化容器,里面可以装任意类型的负载,而不是把内容拍平成一个字段。
会话边界解决的是"一段独立语境从哪里开始、到哪里结束"。聊天机器人的用户不会只聊一个话题,一个会话里可能连续追问,也可能突然切换话题。会话需要同时具备连续性(能记住上下文)和隔离性(不同的会话不能互相污染)。TinyRobot Kit 用的是多级会话结构,全局会话管长期记忆,局部会话管短期任务,这样既能让模型知道"你是谁、你上次说过什么",又不会让上一个 Excel 分析任务的内容干扰下一个法律咨询问答。
资源边界解决的是"AI 生成的内容和外部数据源如何关联"。做过知识库问答的人应该深有体会,回答里给出引用来源至关重要,但如果消息结构里没有预留关联字段,等产品上线后再补引用体系,成本会非常高。TinyRobot Kit 把资源挂在消息节点上,每条回答都可以追溯到它参考了哪些文档、调用了哪些工具,这对企业级应用几乎是刚需。
这三个边界如果不在一开始定清楚,后面每加一个功能都像在烂泥地上盖楼。我见过很多团队用 Redis 里一个 string 键存整个聊天记录,当时看起来简单,等要接入流式协议、要做会话隔离时,整个数据层都要重写,那个痛苦我深有体会。
2. 消息模型设计:从字符串到事件流的升级
2.1 领域模型里必须包含的四个对象:用户、机器人、消息、会话
在零基础开始做的时候,最诱人的方案是拿一张表存聊天日志——字段就是 id、sender、content、timestamp。这个方案在 demo 阶段跑得飞快,但一旦进入生产环境,立刻会暴露出三个问题:第一,无法表达"这条消息是第几轮里模型针对第几条用户提问生成的回答";第二,无法处理一条消息里同时包含文本、工具调用、状态更新的事件流;第三,无法支持一个多轮会话在时间线上被反复加载和回放。
TinyRobot Kit 的领域模型把这四个对象拆得很干净。用户(User)描述的是对话一方的身份,机器人(Robot)描述的是 AI 角色,消息(Message)是对话的基础单元,而会话(Session)把消息串联成一段有边界的交互历史。关键在于 Message 和 Session 的设计,不是简单的一对多挂载,而是让消息带有人工智能运行期间产生的元数据,包括消息属于哪个上下文窗口、是否包含工具调用结果、它在流式通道里是否已经结束。这样设计之后,一个 AI Agent 内部在思考时产生的中间状态,也能作为内部的参与方数据被记录和追溯,而不是被当作噪声丢掉。
我举个具体的例子。假设用户问了一句"帮我查一下昨天的销售额,然后画一张趋势图",模型内部会先调用一个数据查询工具,拿到结果后再生成一段总结,最后触发一个画图动作。在传统的聊天记录表里,这整个过程在数据库里只有一行——用户发了什么、模型回复了什么。如果系统崩溃或者需要审计,这段回答是怎么算出来的,靠看数据库是永远看不明白的。而在 TinyRobot Kit 的数据模型里,"查销售额"是一个工具消息,"数据结果集"是一个资源消息,"画趋势图"是一个动作消息,"最终回复文案"是模型消息。这些消息在会话里组成一条完整的事件链路,可以做真正意义上的回放和复盘。
2.2 消息状态机:准备、发送、完成、失败与中断
消息不是一次性写进去就完事的。在流式场景下,一条模型消息可能经历多个状态:正在排队等待模型响应、正在流式输出中、已经输出完毕、因为网络中断或模型报错而终止。如果数据层不记录这些状态,前端只能靠猜测来渲染 UI。
TinyRobot Kit 给每条消息设了一个明确的状态机。比较核心的几个状态是:pending(已创建但还没发送给模型)、streaming(正在接收模型的流式数据)、completed(正常结束)、interrupted(用户主动打断或系统异常终止)、failed(调用模型失败)。这里面最容易忽略的是 interrupted 状态,因为用户随时可能点击"停止生成"按钮,而流式输出被中断之后,已经收到的内容不该被丢弃,也不该被标记为完整消息。
我在实际项目中处理过一个很典型的 case:用户让模型写一篇长文,写到一半发现开头方向不对,点了停止按钮,然后微调提示词让模型重新生成。如果数据层把这条被中断的消息标记为 completed,历史记录里就会出现一条半截回答,下次会话续接时模型会把这段半截话当成完整上下文,导致新回答风格不连贯。TinyRobot Kit 会把被中断的消息标记为 interrupted,同时在内部记录它实际收到了多少个 token、中断于哪一帧,这样 UI 层就可以明确展示"这条回答未完成"的视觉状态,上下文管理模块也可以决定是否要把这条半截消息排除在模型输入之外。
消息状态机的实现建议放在数据访问层封装,不要让业务代码到处去改状态字段。TinyRobot Kit 内部的做法是把状态流转做成了方法级别的约束,比如只有处于 streaming 状态的消息才能追加增量内容,只有处于 pending 状态的消息才允许取消。这种约束能拦住大量并发场景下的竞态问题。
3. 流式消息处理:内核级语法解析与增量重组
3.1 一次对话的完整生命周期:从用户提问到消息闭环
我先把一次对话在 TinyRobot Kit 里的完整生命周期串一遍,这样后面讲流式解析和会话聚合时会更容易理解。
第一步,用户在前端输入一句话,SDK 生成一条用户消息,并创建一个消息记录,状态置为 pending。第二步,SDK 把这条用户消息连同对应的会话历史一起发送给模型接口,模型开始回答,返回一个流式响应。第三步,消息模块把这个流式响应拆成若干块,一边持续解析,一边将内容落盘到消息载体中,消息状态更新为 streaming。第四步,模型流式输出结束,SDK 收到完成信号,把消息状态更新为 completed,同时更新整个会话的时序版本。第五步,前端在页面上将流式内容还原成完整的消息气泡。
这个链路里最核心的环节是第三步,也就是流式消息的解析与增量重组。如果模型服务端返回的是标准 OpenAI 格式的 SSE 流,那么每条数据都是一个以 data: 开头的字符串,最后以 [DONE] 表示结束。但真实的线上环境远没有这么干净:请求可能经过网关时被缓冲拆分,SSE 可能被 CDN 截断成多个 chunk,网络层可能乱序到达。一套健壮的消息模块,必须能在这种不完美环境下保证内容不丢、顺序不乱、渲染不重。
3.2 流式消息类型拆分:文本增量、工具调用与状态更新
TinyRobot Kit 在流式协议处理上做了比较细致的类型拆分。大多数模型服务在流式返回时会输出多种事件类型,常见的是这三类:文本增量(新增的自然语言内容)、工具调用(模型决定调用某个函数,通常以 function call 的形式出现)、状态更新(比如 usage 信息、结束原因、内容审核标记)。
文本增量的处理相对简单,就是把连续两块 delta 内容追加到消息体的文本缓冲区里。但工具调用的处理要复杂得多。在 Anthropic 和 OpenAI 的函数调用格式中,工具调用的流式返回通常会被拆成多个块,每个块只包含参数 JSON 的一部分,需要自己把碎片拼成完整的 JSON 后再执行工具。这里有两个容易踩的坑:一个是在流式结束前就尝试解析工具参数,导致 JSON 解析报错;另一个是同一个工具调用的多个块之间会插入文本增量,如果没按工具调用 ID 分组,就会把不同调用的参数混在一起。
我建议的做法是,在数据层单独维护一个工具调用缓冲区。收到功能调用块时,先按工具调用 ID 找到对应的缓冲节点,把增量 JSON 追加进去;只有当模型显示发出工具调用结束的信号后,才把完整参数解析出来,触发真正的运维操作。TinyRobot Kit 把这个缓冲区里的中间状态设计成了一组可独立存储的待执行数据,执行后整理成结构化结果再写入历史记录,这样工具执行的结果可以作为后续对话的引用上下文,也可以作为审计日志的一部分被查证。
3.3 增量块的收敛算法:流式数据如何变成完整消息
流式数据从网络到达后,会经过一个收敛过程。TinyRobot Kit 这个项目的关键点,是对增量块做了两级收敛:先做协议层拼接,再做语义层合并。
协议层拼接解决的是网络传输的完整性问题。一个完整的 SSE 数据块可能会被 TCP 拆成两个包,也可能一次到达多个块。接收端需要做的是:按换行符扫描缓冲区,把逻辑上的一条事件完整提取出来,再按事件类型分发到对应的通道。这个逻辑如果写得不够谨慎,很容易出现半个块被当作完整事件解析,导致 JSON.parse 抛错、内容丢失。
语义层合并解决的是业务表达的完整性问题。比如模型在流式输出中先说了一段开场白,接着输出了一个表格 Markdown,然后是结论。如果数据层逐字存储这些内容,渲染端要做大量拼接操作。TinyRobot Kit 的做法是把流式消息统一抽象成可订阅的载体对象,前端多个组件可以同时订阅同一份流,按各自的渲染需求消费增量,后端收到的原始事件序列被合入时,只在消息体的总索引位置做一次追加写入。这样无论订阅方有多少个,消息在存储层的碎片都不会越积越多。
3.4 数据拼包与事件丢包:实际联调中的常见坑
这块是流式解析里实际发生过、并且花费大量时间去排查的典型问题,拿出来讲讲。
第一个坑是拼包错位导致的内容乱序。某次联调中我发现,模型回答里的文本会被调换顺序——一句话前半段跑到了后面、后半段反而在前面。排查后发现,问题出在网关层把几次流式事件的字节块合并后一次性发到了后端,而我的解析逻辑按块到达顺序直接渲染,没有先按事件边界做重组。正确做法是有一个全局的接收缓冲区,每收到一段字节就尝试按 SSE 事件边界切分,切分出完整事件才放行;如果当前缓冲区里只有半个事件,就继续等待下一个包到来。
第二个坑是事件丢包。有的模型服务在流式输出过程中,如果客户端长时间没有读取消,或者连接闲置达到某个阈值,就会自动断开连接。这会导致最后几块文本和完成信号没有被接收到,服务端的状态永远停留在 streaming。TinyRobot Kit 的解决方案是在数据接入层实现超时重连机制,如果发现超过 N 秒没有收到新的增量事件,主动断开并重新发起一次请求,同时要求模型服务支持从上次中断位置续传(也就是 max_tokens 减掉已接收部分,重新生成)。如果服务端不支持续传,就退化为丢弃半截消息,并给用户明确的失败提示。
4. 多会话管理:动态路由与会话隔离
4.1 会话与应用的关系辨析:WebSocket 不等于会话
很多人会把 WebSocket 连接和会话混为一谈,这是一个非常普遍的误解。WebSocket 只是数据传输的通道,一个连接可以在不同时间段服务多个会话;一个会话也可以横跨多个连接(比如用户刷新页面、网络切换)。如果你把 Session ID 等同于 Connection ID,那用户一旦刷新页面,之前的对话上下文就全丢了。
TinyRobot Kit 在数据层引入了相对宽松、面向 AI 场景的 Session 抽象——具体融合为两类会话。当模型内部需要对一段文本做全局语义梳理时,会拉起一个新的全局会话,而全局会话的长期消息会进入记忆表;当模型在执行一次性工具类任务(比如生成一份脚本、分析一个文件)时,会把这个任务放入一个局部的子会话中,子任务结束后可以自行归档或继续追加。这样底层同时支持同域多开和跨模块的资源隔离,顶层用户在页面上逐会话切换时,上下文拼接和 token 控制都发生在会话模块内部,不会因消息串用造成上下文错乱。
4.2 会话的创建、切换与回收生命周期
先讲会话的创建。我建议的准则是:显式创建 + 懒加载。也就是用户第一次发消息时,如果前端没有携带会话 ID,后端会创建一个新的会话记录,并让模型生成一个适合的会话标题;如果前端带了会话 ID,则校验该会话是否存在且属于当前用户,然后加载历史消息。TinyRobot Kit 没有用自动创建模式,因为 AI 场景里模型内部会派生子任务,自动创建很容易产生大量无意义的空会话。
会话的切换涉及上下文窗口的重构。每次切换时,数据层需要把目标会话的历史消息按角色顺序排列好,转换成模型能接受的 messages 格式,再计算 token 数量。这里有个比较容易忽略的问题:一个会话里可能有很多历史消息,但模型的上下文窗口是有限的,数据层必须要有一个健壮的摘要策略,把超出窗口的早期历史先做压缩,而不是简单截断。TinyRobot Kit 在切换时会做进度记录和范围检查,比如会话中有一条很长的工具执行结果,原始内容有 3000 个 token,那么摘要器会先把它压缩成 100 个 token 的结论,后续提问就只带结论不带原文。
会话的回收做了两级处理。用户主动删除会话时,走的是软删除加异步清理;系统判断一个会话超过 N 天未活跃时,先将活跃会话降级为可归档状态,定时任务把完整历史导出为 JSON 文件存档,再从热存储里移除。这样既保证用户随时能找回历史记录,又不会让 Redis 和其他热存储里堆满不再访问的冷数据。
4.3 多会话路由策略:消息体里必须携带会话标识
多会话系统最能考验接口层设计的严谨性。我见过不少团队在前期只有单会话时,把会话 ID 放在前端内存变量里;等要支持多会话时,后端每个接口都要临时补会话 ID 参数,改得焦头烂额。
TinyRobot Kit 规定,所有发给后端的消息体都必须携带全局会话标识字段,这个字段从端侧生成,生命周期从一次完整的多轮交互开始,直到用户点击新对话按钮时为止。后端路由根据这个标识找到对应的会话处理器,再把消息写入正确的会话队列。为了防止出现串会话,TinyRobot Kit 在会话管理器里维护了一张活跃会话表,每个会话记录最后活跃时间和当前状态。当一条新消息到达时,如果它携带的会话 ID 不存在,后端会把它当作一个新的会话请求处理,而不是抛 404——但会在日志里增加一条告警。加上告警是因为,如果前端代码里把某一个旧会话 ID 写死了,用户切到新会话时可能还在往旧会话发消息,这种 bug 不靠日志监控极难发现。
多会话路由的另一种挑战是机器人自己的主动消息。当机器人在后台执行定时任务或收到外部事件触发时,需要主动往某个会话里推送消息——比如用户订阅了一个股票价格提醒。这类消息没有对应的用户请求,如果路由模块只能按"用户发消息找会话"来处理,主动推送就会丢失。TinyRobot Kit 的做法是让主动推送按会话标识来做会话维度的定向路由,推送前先确认目标会话处于活跃状态,如果会话已过期则重新创建一个归档副本并通知用户。内部消息和外部事件都在数据层统一登记,后续要追溯某条主动消息是由哪个外部事件触发的,也一查就能查到。
5. 持久化与数据一致性的工程实现
5.1 存储选型:热存储、历史库与向量库的职责划分
很多人做聊天机器人存储,一张 MySQL 表从头存到尾,存取路径单一,后面想让历史消息参与语义检索、要让长会话跨表分页时,往往无从下手。TinyRobot Kit 的存储方案把数据按生命周期拆成了三块。
热存储保存最近 N 个会话的完整消息,供实时读写和上下文拼接,需要低延迟,采用类 Redis 的结构化存储。历史库保存所有已归档会话的完整事件记录,供用户查询和管理端审计,需要高可靠和复杂查询能力,使用关系型数据库或文档数据库。向量库保存每条用户消息和模型回答的语义向量,用于"相似问题召回"和"历史记忆检索"。
三层存储之间使用同一个全局消息 ID 作为关联键。在热存储里一个 key 对应一个消息对象;归档到历史库时,消息对象被序列化为完整的事件列表;投递到向量库时,只取消息的文本字段做 embedding。任何一个链路出现问题,都不会影响消息主链路。
有一个细节值得提一下:从用户角度看,聊天记录是不应该分页的,应该像聊天软件一样往上滚动就能持续加载。这要求历史库的查询接口支持基于游标的分页,而不是传统的 offset 分页。传统分页在数据量大了以后,深翻页的查询性能会急剧下降。TinyRobot Kit 里所有历史记录查询都要求使用全局消息 ID 作为游标,这在工程上直接决定了消息模块能否支持一个会话几千条历史记录以上的场景。
5.2 双写一致性与幂等机制:防止消息记录错乱
当消息同时写入热存储和历史库时,双写一定会遇到一致性问题。最简单粗暴的方案是先写历史库、再删缓存,但删缓存和写缓存之间存在时间窗,仍然可能读到旧数据。另一个方案是先更新缓存、再异步同步到数据库,但异步任务失败时,缓存里的消息可能永远不回源。
TinyRobot Kit 采用的是基于事件日志的最终一致方案。所有消息写入操作都先追加到一份顺序事件日志(持久化消息队列),再由分发器分别投递到热存储和历史库。因为事件日志本身是单调递增的,任何一个下游存储都可以根据自己记录的位置继续消费,不会重复也不会遗漏。如果某个时刻历史库写入失败,分发器会重试,直到成功为止。这个方案比双写代码更稳妥,排查问题时也能直接从事件日志回放来定位是哪一步丢失了。
幂等机制是另一个关键环节。在流式场景下,客户端可能会因为超时而重发同一个追加请求。如果接收端没有幂等校验,同一条消息内容会被追加两次。TinyRobot Kit 的做法是给每一条 SSE 增量块分配了全局唯一的序列号,接收端在追加前先检查该序列号是否已经存在于消息主体的索引列表中;如果已存在,直接忽略本次追加。这样可以保证即使网络层重复投递了大量数据,消息主体的内容也是唯一的。
5.3 持久化阶段的性能调优:合并写入与自动降级
消息写入频率高、单条数据小,是对话数据层最典型的写入特征。如果每接收一个增量块就执行一次 insert,数据库的连接数会迅速被打满,整体吞吐量很低。TinyRobot Kit 在持久化层做了合并写入:同一会话的多个增量内容先在内存缓冲区积攒,达到一批阈值或时间阈值后,再批量写入存储。缓冲区里的数据同时保留一份待持久化标记,这样即使进程在写入前崩溃,重启后也能把未落盘的数据重新补写。
查询性能的优化点集中在历史会话加载上。一个会话动辄几百条消息,如果加载时逐条查询再拼装,耗时很难看。TinyRobot Kit 的做法是用范围查询一次取出整个会话的全部消息,在内存里按序号组装成消息列表。组装完成后同时触发一次摘要刷新——如果当前会话的 token 总数超过模型窗口的 80%,就自动折叠最早的一部分消息,这能显著降低后续请求的延迟和 API 成本。
还需要考虑自动降级策略。假设历史库发生故障,用户的实时对话不应该随之不可用。TinyRobot Kit 会在检测到历史库连续写入失败时,把持久化模式自动切换为仅内存加本地文件备份的降级状态,同时在后台不断重试恢复。恢复成功后,再把这些本地备份的增量内容补传回存储层。这套降级机制做下来,系统整体的可用性和数据自愈能力都提升明显。
6. 实战复盘:常见异常与排查技巧实录
6.1 会话串线与上下文污染
遇到的现象是:用户在新会话里询问一个完全无关的话题,模型回答里却出现了上一个会话才有的信息。从数据层来排查,串线通常来自三个地方:第一,后端拿错了会话 ID,比如从某个公共配置里读取了默认会话,而不是从请求里拿到真实 ID;第二,上下文拼装时读到了错误的缓存 key,比如缓存 key 没有带用户维度;第三,模型调用时把历史消息数组写成了某个共享内存变量,导致并发请求互相污染。这类问题在单会话演示中永远不会暴露,只有多会话并发出现时才显露真容。
曾经有个案例,某个 Android 客户端把当前会话 ID 存在静态变量里,用户切到后台再切回来时,变量会被系统回收重置成空字符串,后端收到空 ID 后路由到了默认会话,于是不同用户的提问都被汇聚到了同一个默认会话里。那一次排查整整花了两个小时,最后靠给每个会话 ID 增加用户维度校验和活跃校验才定位到根因。TinyRobot Kit 在接口层加的会话归属校验,本质上就是防这种前端状态丢失导致的串线问题。
6.2 多端写入同一条消息导致的内容覆盖
当用户同时在 PC 端和手机端打开同一个会话并发操作时,两个终端各自维护了一条本地消息列表,用户在其中一端发出一条消息后,另一个端的某个操作可能触发全量刷新,把这一端刚收到的新消息覆盖掉。这个问题的根源在于写数据库时缺少乐观锁机制。
TinyRobot Kit 的解法是在消息记录里维护一个版本号字段,每次追加内容时都要求版本号匹配;如果发现版本不一致,说明该消息已经被其他端写入过,接收端就需要丢弃本次追加并触发一次全量重拉。这个策略同样适用于流式消息中断后恢复场景:恢复连接时先拉取当前消息的最新版本,再决定是从中间位置续传还是从头重新拉取。
6.3 流式传输中的乱序问题与前端渲染闪烁
SSE 协议本身是顺序传输,但引入代理层后,多个数据分片可能出现乱序到达的情况。在 TinyRobot Kit 的早期版本中,前端收到乱序的事件块后直接把内容渲染到界面上,导致文字出现跳跃和闪烁。修复方案是:接收后先把全部增量块放入一个按序列号排列的排序缓冲队列,等缺失的序号到达后再触发一次 UI 更新。这样从用户视角看,回答是稳定地一行一行出现,不会出现回退。
前端渲染的另一个常见问题是:如果用虚拟滚动列表渲染聊天记录,对流式追加的插入位置处理不当,会导致滚动条跳回顶部。TinyRobot Kit 的前端适配层把消息列表末尾的新增渲染和用户主动上翻历史做了严格区分,只有当用户停留在列表底部时才触发自动滚动。这个体验细节看似简单,但到了真实场景里,对流式长回答滚动手感的影响比想象中大得多。
6.4 工具调用异常时,消息链路如何回滚和重试
模型在处理一个复杂问题时可能会连续调用多个工具,如果中间的某个工具抛了异常,链路状态就变得复杂。比如模型先调了数据库查询工具,然后调了文件生成工具,文件生成失败后,重新执行整个链路会浪费一次查询费用;不重新执行,文件生成没有结果,后续回答不完整。
TinyRobot Kit 在实现上把工具调用消息做了子状态机:pending、executing、succeeded、failed。执行失败的工具消息会保留异常堆栈,模型看到失败结果后可以自主决定是重试、换一个工具还是直接告诉用户失败原因。在这个机制下,消息链路很少需要整体回滚,而是把失败的工具节点变成一个可观测、可恢复的事件源。另外,如果某个工具是重操作(比如发邮件、下订单),建议在工具消息里增加幂等键,即使重试也不会让用户收到两封邮件、产生两笔订单。
6.5 流式场景下的消息审计与日志监控
AI 对话系统上线后,消息审计是一个无法绕开的需求。尤其是企业场景里,安全团队需要能回答"这句话是谁在什么时间通过哪个会话问的、模型是怎么回答的、回答依据是什么"。TinyRobot Kit 在每条消息创建时都会写一条审计日志,包含消息 ID、用户 ID、会话 ID、消息类型、令牌消耗、模型名称、耗时等元数据。当有用户投诉回答有误时,运维可以直接根据消息 ID 拉出模型的完整输入历史,溯源整个推理链路。
日志监控里有一个值得关注的指标叫"消息追加失败率"——也就是流式接收端在处理增量块时,因为写入存储失败或序列号重复而被拒绝的比例。这个指标比单纯看 API 错误率更能反映数据层的健康状况。在一个稳定运行的对话系统里,这个比例应该趋近于零;监控告警阈值建议设在 0.1%,一旦超过就要立刻检查存储层状态。
7. 给要自己造类似 Kit 的人几点建议
如果看完这篇,打算在自己项目里照着 TinyRobot Kit 的思路去重构对话数据层,我最后分享几条实操建议,都是拿真金白银换来的经验。
第一,不要一开始就追求大而全。先把消息对象的几类核心协议定死:唯一 ID、会话 ID、消息类型、内容载体、状态、时间戳、扩展属性。这些字段是数据层的地基。然后再逐步加入版本号、引用资源、工具调用信息等扩展字段,消息结构保持稳定很重要,业务功能迭代时不要轻易改主表结构。
第二,强烈建议给消息 ID 设计成全局唯一且有序的格式。我推荐用雪花算法或类似方案,而不是数据库自增 ID。全局唯一能做到多端写入不冲突,有序则能让消息按时间线天然排序。TinyRobot Kit 内部使用带时间戳前缀和序列号后缀的复合 ID,这样既能在日志里直接看出消息的创建时间,又能保证同一毫秒内的高并发写入不会撞 ID。
第三,会话维度的缓存一定要有主动过期和被动过期双重机制。主动过期是定时任务去清理超时未活跃的会话,被动过期是每次读取会话时都检查当前时间是否已超过会话有效期。只有主动没有被动,会出现缓存雪崩;只有被动没有主动,会出现冷数据堆积。
第四,不要把所有处理塞进一个巨无霸类。把消息接收器、流式解析器、会话存储器、历史归档器拆开,彼此通过消息事件通信。这不仅是代码洁癖,而是因为对话数据层的处理链路确实不同阶段有不同的故障场景和扩展需求,拆开之后可以独立扩容和独立降级,排障时也容易定位。
就在上周,我接手排查了一个客户系统的线上事故:每天凌晨业务高峰期,MySQL 的 CPU 使用率飙升到 100%。查到最后,问题就是他们的消息写入接口在每收到一个流式块时都发送了一次 INSERT 操作,没有做合并写入,也没有设置连接池上限。这种问题,在架构设计阶段做好了,根本不可能发生;没做好的时候,线上每一秒都是煎熬。
对话数据层的设计没有银弹,但它有确定性的规律:消息要可追溯、流式要可重组、会话要可隔离、存储要可降级。把这四条刻在项目初期的设计文档里,后面接入再复杂的 Agent 能力,数据层都不会拖后腿。