跳至主要内容
返回博客
  • AI
  • Agent
  • 数据库

Agent 的 Message 表怎么设计:持久化、压缩与上下文

用 PostgreSQL、Drizzle ORM 与 Vercel AI SDK 7,把 Agent 的消息存得可靠、查得明白。

Agent 的 Message 表怎么设计:持久化、压缩与上下文

在聊天演示中,message 表通常只需要 idrolecontentcreated_at 等字段。但当 Agent 真正运行在生产环境后,还需要处理流式输出、工具调用、重试、人工审批、隐私数据删除和上下文压缩等问题。

因此,消息表设计的重点不只是保存一条消息,而是区分下面三种数据:用户看到的聊天记录、系统保存的原始历史、某次调用实际发给模型的上下文

本文以 PostgreSQL、Drizzle ORM 0.45 和 Vercel AI SDK 7 为例,介绍一套可以从小项目开始使用的设计方案。先给出结论:

原始消息是底稿;摘要是可以重新生成的副本;模型上下文则是每次临时拼出来的输入。别让它们互相覆盖。

区分 UIMessage 和 ModelMessage

在 AI SDK 中,UIMessageModelMessage 的用途不同,需要先区分清楚。

  • UIMessage 用来展示和保存,里面有稳定的 idrolemetadata 和结构化的 parts
  • ModelMessage 才是发给模型的内容,可能有 userassistanttool 等协议角色。它是从历史里转换、过滤、裁剪出来的。
  • AI SDK 7 的 UIMessage 只有 systemuserassistant 三种角色。工具调用、工具结果、审批状态、reasoning、source、file 都放在 parts 里,不是一条 role = tool 的 UI 消息。

所以主消息表最好按 UIMessage 的语义来存,不要把某个模型供应商眼下接受的 ModelMessage JSON 当成永久格式。真正调用模型前,再用 validateUIMessagesconvertToModelMessages 转一遍。

AI SDK 7 默认不接受 messages 里的 system 消息。可信的系统提示词应该由服务端通过 instructions 加进去,并单独记录 agent_versionprompt_version。别让客户端借着历史消息写入系统提示词。旧数据确实要兼容时可以开 allowSystemInMessages,但前提是内容已经过信任校验。

两种需要避免的设计方案

把整段 UIMessage[] 都塞进 conversation.messages jsonb,一开始很快,但每次回复都得重写一整行。并发时容易互相覆盖,想单独删一条、做幂等重试、分页、审计和归档,也会越来越麻烦。

另一个极端是把 text、file、tool input、tool output 全拆成关系表。查询是细了,但 SDK 每多一种 part 你都要迁移,渲染一条消息也要做一堆 join。

比较实用的折中是:

  1. 一条 agent_message 对应一条 UIMessage
  2. 稳定且常用于过滤、排序、约束的字段使用普通列。
  3. 变化快、需要原样还原界面的 partsmetadata 使用 jsonb
  4. 工具审批、文件、成本这类有自己生命周期的数据,再单独建表;别把整个 parts 系统拆成 EAV。

主表里放什么

下面的 schema 保留了 threadrun。没有它们,消息顺序、模型成本、流式状态就很容易全被硬塞进 message 一行里。

db/schema/agent.ts
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_artifactkind、覆盖序号范围、source_hashcontentprompt_versionmodelinvalidated_at保存摘要、工具结果 digest、长期记忆等可失效派生物
context_snapshotrun_idstep_numberpolicy_versionbudget_tokensestimated_tokens记录一次模型调用采用的上下文策略
context_snapshot_itemsnapshot_idordinalmessage_idartifact_idtoken_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 就要标记为失效。

这样既能追溯,又不会拿“只追加、不修改”当借口,妨碍必要的数据治理。

流式写入也要考虑中断后怎么办

一套比较稳的写入流程可以是:

  1. 服务端鉴权,检查 thread 的 tenant 与成员关系。
  2. client_request_id 幂等插入 user message,并在同一事务中分配 sequence。
  3. 创建 agent_runstreaming 状态的 assistant message,预先得到稳定 ID。
  4. 流式输出先放在内存或 Redis,隔 250–1000ms 合并写一次 checkpoint;不要每个 token 都写 PostgreSQL。
  5. AI SDK 的 onEnd 中以最终 responseMessage 完成数据库行,记录 finishReason、usage 和状态。
  6. 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 的 ToolLoopAgentgenerateTextstreamText 都能在 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_stateexclusion_reason;只是本次预算不够而没选中的内容,写进 context snapshot 就好。这样能区分:

  • 永久不应进入模型:敏感数据、UI 事件、已撤回内容;
  • 当前策略不需要:旧工具噪音、低相关历史;
  • 本次预算放不下:仍保留在历史中,下次可能重新入选。

过滤器也应该输出 reason code,例如 authorization_deniedui_onlystale_tool_tracesupersededlow_relevancebudget_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 变成长期稳定的服务,往往就差在这里。

参考资料