在聊天演示中,message 表通常只需要 id、role、content、created_at 等字段。但当 Agent 真正运行在生产环境后,还需要处理流式输出、工具调用、重试、人工审批、隐私数据删除和上下文压缩等问题。
因此,消息表设计的重点不只是保存一条消息,而是区分下面三种数据:用户看到的聊天记录、系统保存的原始历史、某次调用实际发给模型的上下文。
本文以 PostgreSQL、Drizzle ORM 0.45 和 Vercel AI SDK 7 为例,介绍一套可以从小项目开始使用的设计方案。先给出结论:
原始消息是底稿;摘要是可以重新生成的副本;模型上下文则是每次临时拼出来的输入。别让它们互相覆盖。
区分 UIMessage 和 ModelMessage
在 AI SDK 中,UIMessage 和 ModelMessage 的用途不同,需要先区分清楚。
UIMessage用来展示和保存,里面有稳定的id、role、metadata和结构化的parts。ModelMessage才是发给模型的内容,可能有user、assistant、tool等协议角色。它是从历史里转换、过滤、裁剪出来的。- AI SDK 7 的
UIMessage只有system、user、assistant三种角色。工具调用、工具结果、审批状态、reasoning、source、file 都放在parts里,不是一条role = tool的 UI 消息。
所以主消息表最好按 UIMessage 的语义来存,不要把某个模型供应商眼下接受的 ModelMessage JSON 当成永久格式。真正调用模型前,再用 validateUIMessages 和 convertToModelMessages 转一遍。
AI SDK 7 默认不接受 messages 里的 system 消息。可信的系统提示词应该由服务端通过 instructions 加进去,并单独记录 agent_version 或 prompt_version。别让客户端借着历史消息写入系统提示词。旧数据确实要兼容时可以开 allowSystemInMessages,但前提是内容已经过信任校验。
两种需要避免的设计方案
把整段 UIMessage[] 都塞进 conversation.messages jsonb,一开始很快,但每次回复都得重写一整行。并发时容易互相覆盖,想单独删一条、做幂等重试、分页、审计和归档,也会越来越麻烦。
另一个极端是把 text、file、tool input、tool output 全拆成关系表。查询是细了,但 SDK 每多一种 part 你都要迁移,渲染一条消息也要做一堆 join。
比较实用的折中是:
- 一条
agent_message对应一条UIMessage。 - 稳定且常用于过滤、排序、约束的字段使用普通列。
- 变化快、需要原样还原界面的
parts与metadata使用jsonb。 - 工具审批、文件、成本这类有自己生命周期的数据,再单独建表;别把整个 parts 系统拆成 EAV。
主表里放什么
下面的 schema 保留了 thread 和 run。没有它们,消息顺序、模型成本、流式状态就很容易全被硬塞进 message 一行里。
import { sql } from 'drizzle-orm'
import type { UIMessage } from 'ai'
import {
bigint,
check,
foreignKey,
index,
integer,
jsonb,
pgEnum,
pgTable,
text,
timestamp,
unique,
uniqueIndex,
uuid,
} from 'drizzle-orm/pg-core'
type StoredMessageParts = UIMessage['parts']
type StoredMessageMetadata = Record<string, unknown>
export const messageRole = pgEnum('agent_message_role', [
'system',
'user',
'assistant',
])
export const messageStatus = pgEnum('agent_message_status', [
'pending',
'streaming',
'completed',
'failed',
'cancelled',
])
export const contextState = pgEnum('agent_message_context_state', [
'eligible',
'excluded',
'redacted',
])
export const runStatus = pgEnum('agent_run_status', [
'queued',
'running',
'completed',
'failed',
'cancelled',
])
export const agentThreads = pgTable(
'agent_thread',
{
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
nextSequence: bigint('next_sequence', { mode: 'number' })
.notNull()
.default(0),
lastMessageAt: timestamp('last_message_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
unique('agent_thread_id_tenant_unique').on(table.id, table.tenantId),
index('agent_thread_tenant_recent_idx').on(
table.tenantId,
table.lastMessageAt.desc()
),
]
)
export const agentRuns = pgTable(
'agent_run',
{
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
threadId: uuid('thread_id').notNull(),
status: runStatus('status').notNull().default('queued'),
provider: text('provider').notNull(),
model: text('model').notNull(),
inputTokens: integer('input_tokens'),
outputTokens: integer('output_tokens'),
startedAt: timestamp('started_at', { withTimezone: true }),
finishedAt: timestamp('finished_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
unique('agent_run_id_tenant_thread_unique').on(
table.id,
table.tenantId,
table.threadId
),
foreignKey({
columns: [table.threadId, table.tenantId],
foreignColumns: [agentThreads.id, agentThreads.tenantId],
name: 'agent_run_thread_tenant_fk',
}).onDelete('cascade'),
index('agent_run_tenant_thread_created_idx').on(
table.tenantId,
table.threadId,
table.createdAt.desc()
),
]
)
export const agentMessages = pgTable(
'agent_message',
{
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
threadId: uuid('thread_id').notNull(),
runId: uuid('run_id'),
sequence: bigint('sequence', { mode: 'number' }).notNull(),
clientRequestId: text('client_request_id'),
role: messageRole('role').notNull(),
status: messageStatus('status').notNull().default('pending'),
parts: jsonb('parts')
.$type<StoredMessageParts>()
.notNull()
.default(sql`'[]'::jsonb`),
metadata: jsonb('metadata')
.$type<StoredMessageMetadata>()
.notNull()
.default(sql`'{}'::jsonb`),
contextState: contextState('context_state')
.notNull()
.default('eligible'),
exclusionReason: text('exclusion_reason'),
schemaVersion: integer('schema_version').notNull().default(1),
contentVersion: integer('content_version').notNull().default(1),
byteSize: integer('byte_size').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow(),
completedAt: timestamp('completed_at', { withTimezone: true }),
redactedAt: timestamp('redacted_at', { withTimezone: true }),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
},
(table) => [
foreignKey({
columns: [table.threadId, table.tenantId],
foreignColumns: [agentThreads.id, agentThreads.tenantId],
name: 'agent_message_thread_tenant_fk',
}).onDelete('cascade'),
foreignKey({
columns: [table.runId, table.tenantId, table.threadId],
foreignColumns: [agentRuns.id, agentRuns.tenantId, agentRuns.threadId],
name: 'agent_message_run_tenant_thread_fk',
}).onDelete('restrict'),
uniqueIndex('agent_message_thread_sequence_uidx').on(
table.threadId,
table.sequence
),
uniqueIndex('agent_message_client_request_uidx')
.on(table.tenantId, table.threadId, table.clientRequestId)
.where(sql`${table.clientRequestId} is not null`),
index('agent_message_tenant_thread_recent_idx')
.on(table.tenantId, table.threadId, table.sequence.desc())
.where(sql`${table.deletedAt} is null`),
index('agent_message_run_idx')
.on(table.runId)
.where(sql`${table.runId} is not null`),
check('agent_message_sequence_positive', sql`${table.sequence} > 0`),
check(
'agent_message_parts_is_array',
sql`jsonb_typeof(${table.parts}) = 'array'`
),
check(
'agent_message_content_version_positive',
sql`${table.contentVersion} > 0`
),
]
)这段 schema 里,有几件事比具体字段名更重要。
排序用 sequence,别只看 created_at
时间戳只能参考,不能当严格顺序。并发请求、时钟精度、重试和流式更新,都可能让两条消息时间一样,甚至看起来倒过来。每个 thread 用递增的 sequence 排序,再用唯一索引兜底,顺序才不会乱。
可以在事务里原子分配序号:
import { and, eq, sql } from 'drizzle-orm'
const [slot] = await tx
.update(agentThreads)
.set({
nextSequence: sql`${agentThreads.nextSequence} + 1`,
})
.where(
and(
eq(agentThreads.id, threadId),
eq(agentThreads.tenantId, tenantId)
)
)
.returning({ sequence: agentThreads.nextSequence })
if (!slot) {
throw new Error('Agent thread not found')
}更新 thread 这一行时,同一个会话的序号会自然排队;不同会话不会互相卡住。吞吐量特别高时,可以换成独立 sequence、分段号段或事件流,但别用 max(sequence) + 1,并发下很容易撞车。
parts 存内容,metadata 别管权限
parts 用来存恢复 UI 所需的结构化内容;metadata 适合放 trace ID、展示标签、客户端版本之类的扩展信息。租户 ID、用户 ID、权限、消息状态、删除状态、顺序这些关键数据,必须是能加约束的普通列,不能放在客户端可提交的 JSON 里。
不要一开始就给 parts 建 GIN 索引。大多数请求只是按 thread 和 sequence 把整条消息读出来,GIN 反而会增加写入和存储成本。等真的有稳定的 JSON 查询,再针对表达式或路径加索引。全文搜索也应该查单独、脱敏后的搜索字段。
token 数别只挂在 message 上
同一条消息换个模型、tokenizer、工具定义或转换策略,token 数都可能不一样。模型返回的准确 usage 应该记到 agent_run 或更细的 agent_run_step;估算缓存则用 (message_id, content_version, tokenizer_key) 做联合键。只放一个 message.token_count,过不了多久就不可信了。
还需要三类辅助表
只靠 message 表,解释不了“这次模型为什么看到了这些内容”。至少再准备下面三类数据:
| 表 | 关键字段 | 作用 |
|---|---|---|
context_artifact | kind、覆盖序号范围、source_hash、content、prompt_version、model、invalidated_at | 保存摘要、工具结果 digest、长期记忆等可失效派生物 |
context_snapshot | run_id、step_number、policy_version、budget_tokens、estimated_tokens | 记录一次模型调用采用的上下文策略 |
context_snapshot_item | snapshot_id、ordinal、message_id 或 artifact_id、token_estimate | 按精确顺序记录本次真正发送的消息与压缩产物 |
如果 Agent 还有工具审批、重试、异步执行,再加一张 tool_invocation 表:存 tool_call_id、工具名、参数、输出引用、审批人、状态、幂等键和耗时。消息 parts 可以留下 UI 快照,但状态机和唯一约束应该放在工具执行表里。
这里最关键的是 context_snapshot_item。只记“用了第 1 到 80 条消息的摘要”,遇到消息编辑、分支或选择性召回就说不清了。把有顺序的 message/artifact ID 存下来,出问题时才能知道模型到底看到了什么。
消息完成后尽量别改原文
“原始记录尽量不改”不等于一行永远不能更新。assistant 在流式输出结束前本来就要更新;隐私法规也可能要求真删敏感内容。更贴切的规则是:
pending → streaming → completed | failed | cancelled是允许的状态迁移。- streaming 期间按时间片或内容块合并写入,不要每个 token 执行一次
UPDATE。 - completed 后用户要编辑,就生成
message_revision或替代消息,并增加content_version,别悄悄覆盖历史。 - 普通删除先写
deleted_at;安全或合规删除应清空/加密销毁敏感 parts,同时保留不含内容的审计墓碑。 - 摘要的
source_hash应从有序的(message_id, content_version)算出来。来源一变,相关 artifact 就要标记为失效。
这样既能追溯,又不会拿“只追加、不修改”当借口,妨碍必要的数据治理。
流式写入也要考虑中断后怎么办
一套比较稳的写入流程可以是:
- 服务端鉴权,检查 thread 的 tenant 与成员关系。
- 用
client_request_id幂等插入 user message,并在同一事务中分配 sequence。 - 创建
agent_run与streaming状态的 assistant message,预先得到稳定 ID。 - 流式输出先放在内存或 Redis,隔 250–1000ms 合并写一次 checkpoint;不要每个 token 都写 PostgreSQL。
- AI SDK 的
onEnd中以最终responseMessage完成数据库行,记录finishReason、usage 和状态。 - worker 定期把卡在 streaming 太久的消息改成 failed,并留下最后一次 checkpoint。
AI SDK 官方示例允许客户端只提交最后一条消息,让服务端加载历史。线上还要再收紧一步:客户端发来的是一条候选新消息,不是权威历史。服务端只接受它有权创建的 user parts,不能相信客户端回传的 assistant、tool output、system role 或 usage。
下面只展示边界处理,不是一份完整路由:
import {
convertToModelMessages,
createUIMessageStreamResponse,
streamText,
toUIMessageStream,
validateUIMessages,
} from 'ai'
const storedMessages = await loadAuthorizedMessages({ tenantId, threadId })
const candidateMessages = [...storedMessages, incomingUserMessage]
const uiMessages = await validateUIMessages({
messages: candidateMessages,
metadataSchema,
dataSchemas,
tools,
})
const modelMessages = await convertToModelMessages(uiMessages, { tools })
const result = streamText({
model,
instructions: trustedServerInstructions,
messages: modelMessages,
tools,
})
// 需要“断开后继续生成”时显式消费;同时要提供真正的取消接口。
void result.consumeStream()
return createUIMessageStreamResponse({
stream: toUIMessageStream({
stream: result.stream,
originalMessages: uiMessages,
generateMessageId: () => crypto.randomUUID(),
onEnd: async ({ responseMessage, isAborted, finishReason }) => {
await finalizeAssistantMessage({
finishReason,
isAborted,
message: responseMessage,
runId,
})
},
}),
})AI SDK 7 把这类生命周期回调的推荐名称从 onFinish 改成了 onEnd,顶层 system 也改成了 instructions。旧名字也许还能兼容,但新代码就别继续往旧写法上加债了。
如果刷新页面后还想接回同一条 SSE 流,只有 PostgreSQL 消息表不够。官方方案需要保存 active stream ID,再用 Redis 一类的流存储连接 publisher 和 subscriber。这时“关掉页面”只是断开连接;要真的停止生成,得有独立的 cancel endpoint,更新 run/message 状态并停掉后台任务。
上下文不是把最近消息一股脑塞进去
从数据库取最近 100 条消息直接发给模型,不叫上下文管理。比较靠谱的 context builder 要按固定顺序做事:
鉴权与租户隔离
→ schema 验证与版本迁移
→ context_state / 隐私 / 可见性过滤
→ UIMessage 转 ModelMessage
→ 无损裁剪 reasoning、旧工具轨迹和空消息
→ 选择最近对话、固定事实、相关记忆与未完成任务
→ 必要时生成或复用摘要 artifact
→ 按原子 turn 做最终硬截断
→ 写入 context snapshot
→ 调用模型这个顺序别随便换。权限过滤一定要在做摘要之前,不然一条已经无权访问的消息,可能还会留在旧摘要里。工具调用和结果也要一起处理,不能只留半截 tool result。当前用户请求、可信 instructions、没处理完的审批、当前 step 的工具结果,通常优先级最高。
先算够不够放,再决定删什么
上下文预算不是模型窗口的全部:
可用输入预算
= 模型上下文上限
- 最大输出预留
- instructions 与工具定义
- 当前用户输入和附件
- 安全余量安全余量可以先留总窗口的 10%–20%,再按真实 usage 调整。JSON.stringify(messages).length / 4 只能粗略判断要不要压缩,不能拿来计费,也不能精确防止超窗。
Agent 每跑一次工具都会多出新输入,所以每个 step 都要重新检查预算。AI SDK 的 ToolLoopAgent、generateText、streamText 都能在 prepareStep 返回新的 messages,适合在这里做 step 内压缩:
import { pruneMessages, ToolLoopAgent, type ModelMessage } from 'ai'
const agent = new ToolLoopAgent({
model,
instructions: trustedServerInstructions,
tools,
prepareStep: async ({ messages, stepNumber }) => {
const budget = await calculateStepBudget({ messages, model, stepNumber })
if (budget.fits) {
return {}
}
const prunedMessages = pruneMessages({
messages,
reasoning: 'all',
toolCalls: 'before-last-3-messages',
emptyMessages: 'remove',
})
const compactedMessages: ModelMessage[] = await compactWithArtifacts({
budget,
messages: prunedMessages,
})
await saveContextSnapshot({
messages: compactedMessages,
policyVersion: 'context-v3',
stepNumber,
})
return { messages: compactedMessages }
},
})pruneMessages 只处理 ModelMessage[]。它能删 reasoning、旧 tool call/result/approval,以及裁剪后空掉的消息;但它不会判断哪些业务信息重要,也不会自动写出可信摘要。它只是压缩中的一层,不是完整的 memory 系统。
压缩最好分四步做
第一级:无损过滤
先移除本来就不该进模型的内容:UI-only data parts、展示 metadata、重复状态通知、provider 的原始调试字段、旧 reasoning、已经没后续价值的工具轨迹。工具输出特别大时,把原始对象放对象存储或业务表,上下文里只留受控摘要和引用 ID。
第二级:相关性选择
除了最近几轮,还要留下 pinned constraints、用户长期偏好、没完成的计划、当前引用的文档,以及和新请求有关的历史片段。语义召回不能代替 recent window:最新的否定、纠正和指代,单靠 embedding 往往找不准。
第三级:可追溯摘要
摘要 artifact 至少要记来源消息 ID/版本、覆盖范围、摘要 prompt 版本、生成模型、创建时间、token 估算和 source hash。摘要内容最好分清:
- 已确认事实与用户约束;
- 已完成动作与可验证结果;
- 未完成任务、失败与阻塞;
- 不确定推断和待确认信息。
摘要是有损缓存,不是历史原文。用户要看原话、做审计或重新生成时,还是得回到原始消息。
第四级:原子截断
硬截断是最后一道保险。按完整 turn 或完整工具交互单元,删掉最旧、优先级最低的内容;别从一条 JSON 中间按字符硬切,也别只留 tool call 不留 result。还超预算的话,就缩短附件、工具输出或最大输出预留,并记下为什么降级,别悄悄发出一份谁都解释不清的上下文。
为什么没选中,也要说得清
别只放一个 is_context_eligible boolean。一个 true/false 说不清消息为什么被排除,也分不出是策略排除、用户删除、安全脱敏,还是这次预算不够。
主表可以保存稳定的 context_state 和 exclusion_reason;只是本次预算不够而没选中的内容,写进 context snapshot 就好。这样能区分:
- 永久不应进入模型:敏感数据、UI 事件、已撤回内容;
- 当前策略不需要:旧工具噪音、低相关历史;
- 本次预算放不下:仍保留在历史中,下次可能重新入选。
过滤器也应该输出 reason code,例如 authorization_denied、ui_only、stale_tool_trace、superseded、low_relevance、budget_cut。线上质量变差时,才能查出是召回没找对、摘要失真,还是预算卡得太紧。
多租户隔离,先从表结构做起
tenant_id 同时出现在 thread、run、message 上,看上去有点重复,但这样每次查询和 RLS 都能在当前表完成,复合外键也能阻止跨租户关联。
PostgreSQL 可以再加行级安全作为纵深防御:
alter table agent_message enable row level security;
create policy agent_message_tenant_policy on agent_message
using (tenant_id = current_setting('app.tenant_id', true)::uuid)
with check (tenant_id = current_setting('app.tenant_id', true)::uuid);用了连接池时,必须在事务范围里用 set_config(..., true) 设置租户。没有租户上下文就默认拒绝,别复用上一个请求留下的 session 值。
工具输出很容易带出 access token、内部 URL、个人数据,甚至整篇文档。在写入 parts、日志、trace、摘要、embedding 前,都要统一脱敏。数据库静态加密不能代替应用层字段保护;如果用 envelope encryption,就把用于检索的脱敏字段和密文正文分开。
索引、归档和分区别抢跑
先看真实查询,别一上来就给每列加索引。多数消息系统最常跑的就这几类:
- 按 tenant/thread/sequence 读取一页;
- 用 client request ID 做幂等去重;
- 按 run 查 assistant 输出;
- 找到超时的 streaming 消息;
- 按 retention policy 扫描旧数据。
前面三类已经有示例索引;查超时任务时,可以加一个只覆盖 status = 'streaming' 的部分索引。PostgreSQL 只有在查询条件能推出 partial index 的 predicate 时才会用它,所以应用里的查询条件要和索引条件对应,并用 EXPLAIN (ANALYZE, BUFFERS) 验证。
别因为“以后数据可能很多”就提前分区。分区会牵动主键、唯一约束、外键和迁移。等消息真到数亿级、按月删除造成明显的 vacuum 压力,或冷热存储成本成了问题,再按 retention 选择 created_at 范围分区,或按 tenant/thread 做 hash 分区。归档前也要确认摘要、审计、删除请求能跨冷热层追踪。
上线前过一遍这份清单
上线前,至少把这些问题答清楚:
- 数据库保存的是可恢复的
UIMessage,还是供应商相关的临时ModelMessage? - parts 是否能完整表达 text、file、tool、approval、source 与 data?
- user 与 assistant ID 是否稳定,重试是否有唯一幂等键?
- 同一 thread 的顺序是否由数据库约束,而不是时间戳猜测?
- 客户端是否只提交新 user message,服务端是否重新加载权威历史?
- 完整消息、压缩 artifact 与模型调用 snapshot 是否彼此独立?
- 权限过滤是否早于摘要、embedding 和日志写入?
- tool call/result/approval 是否按完整生命周期裁剪?
- 每个 agent step 是否重新计算 token 预算?
- 是否能从 run 定位到 prompt 版本、模型、usage、上下文快照与最终消息?
- streaming 卡死、客户端断开、主动取消与进程崩溃是否各有恢复状态?
- 编辑、删除、脱敏和摘要失效是否会更新
content_version或 source hash?
最后
线上 Agent 的 message 表不该包办所有事。它最重要的职责,是稳定保存用户看得到、能审计、能迁移的原始消息;run 记录执行过程,artifact 存压缩结果,snapshot 说明每次给模型发了什么。
把“历史”和“上下文”分开后,压缩、截断、过滤就不再是可能伤到数据的清理脚本,而是一套能版本化、能测试、能观察的流程。Agent 能不能从 demo 变成长期稳定的服务,往往就差在这里。