AI 回复比用户提问还早?一个空字符串引发的“时间倒流“ 我今天在查 AI 聊天记录时看到一条很离谱的数据assistant message 的 created_at 竟然比触发它的 user message 还早。AI 难道会时间倒流当然不是。最后查下来根因居然是一个空字符串id 。项目背景:考虑需要快速开发,以及方便定位问题和拓展新需求,所以该项目的技术栈选用的是 Next.js Vercel AI SDK Supabase。服务端持久化消息:用户消息在流式响应前存,assistant 消息在 onEnd 回调里用 upsert(..., { onConflict: id }) 存。UI 一切正常,数据库里却是另一个世界。问题一:数据库里 assistant 消息的 id 是空字符串我在 Postgres 表结构中将 id 设置的属性为 primary key。PRIMARY KEY 隐含 NOT NULL,我以为空值必然被拦住。但是居然出现了毫无报错,但是 assistant 的 id 为空字符串的情况。后面我才发现,PRIMARY KEY 能拦住 NULL但拦不住 。在 Postgres 眼里 是一个合法字符串。想要真正的保护得加 CHECK 约束:ALTER TABLE chat_messages ADD CONSTRAINT id_not_empty CHECK (length(trim(id)) 0);问题二:为什么会出现 id 为空字符串的情况根因:AI SDK 只有在你传了 generateMessageId 时才会生成服务端消息 id。不传的话,SDK 内部直接兜底:messageId: messageId ! null ? messageId : 于是持久化回调里拿到的 responseMessage.id 就是 。start chunk 只有在你传了 generateMessageId 时才会带 messageId。不传的话,服务端这份 id 永远不会被覆盖。问题三:时间倒流的真相每一轮 assistant 消息的 id 都是 ,onConflict: id 的 upsert 让它们全部撞到同一行。用一个时间线看会更明显:事件写入 id数据库动作created_at第一次 assistant 回复INSERT 一行10:00第二次 user 提问msg_user_2INSERT 新行10:05第二次 assistant 回复命中同一个主键,UPDATE 旧行仍然是所以并不是 assistant 真的早于 user,而是第二次 assistant 回复没有创建新记录,而是覆盖了第一次那条 id 的旧记录。created_at 记录的是这行数据第一次 INSERT 的时间,不会因为后面的 UPDATE 自动变成当前时间。这就是时间倒流的真相:表面上是时间戳异常,本质上是所有 assistant 回复都被 upsert 到同一行,历史回复一直在被静默覆盖。这不是展示问题,而是数据丢失。那我们应该怎么办?核心修复是让服务端明确生成 assistant message id。比如:import { createIdGenerator } from ai; createAgentUIStreamResponse({ agent, uiMessages: messages, generateMessageId: createIdGenerator({ prefix: msg, size: 16, }), onEnd: async ({ responseMessage }) { await saveMessage(responseMessage); }, });这样 AI SDK 在创建 assistant response message 时,会给它一个稳定的服务端 id,onEnd 里拿到的 responseMessage.id 就不再是 。如果涉及续写已有 assistant 消息,再配合 originalMessages 使用,让 SDK 正确判断这次是在延续旧消息,还是创建一条新的 assistant 消息。数据库层也要加一道兜底,不要让空字符串 id 再混进来:ALTER TABLE chat_messages ADD CONSTRAINT id_not_empty CHECK (length(trim(id)) 0);前者解决源头,后者防止同类坏数据再次入库。最后这个 bug 反而验证了我当初的选型。我选 Vercel AI SDK 就是看中它够薄——出问题时可以直接读源码。这次从症状 → 持久化回调 → SDK 内部 → 根因,只花了几分钟。换成重型黑盒 Agent 框架,同样的问题可能要查比较久。几条教训:用 AI SDK 做服务端持久化,一定要传 generateMessageId(续写场景再加 originalMessages)PRIMARY KEY / NOT NULL ≠ 非空字符串我正在公开构建一个 AI 会议总结应用,这是开发记录的第一篇,后续会持续分享踩坑实录和从 demo 走向生产的全过程。欢迎关注,一起见证它上线。