opencode v2 会话消息形态设计:User/Assistant 存储模型、PromptMessage 与 Prompt Mutators 三种方案对比
【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode
本文基于 opencode 仓库中的 v2 设计规范文档 message-shape.md,深入解析 v2 会话存储层"消息形态(Message Shape)"的设计动机、三种候选方案的完整类型定义与权衡取舍,并结合 packages/schema/src/session-message.ts 中的现有实现源码说明每种方案相对现状的具体改进点。读完本文,你将理解为什么"持久化历史"与"提示词改写(prompt surgery)"应当分离,以及 v2 消息结构如何以更小的存储体积支撑会话的重放(replay)与恢复(resume)。
问题陈述:存储消息与提示词改写的需求冲突
v2 设计文档开篇给出的问题定义只有三条,但精准命中了编码 Agent 会话存储的两个根本矛盾:
- 存储的消息需要足够的数据,以便日后重放和恢复一个会话(stored messages need enough data to replay and resume a session later);
- 提示词钩子(prompt hooks)往往只是想追加一条合成的 user/assistant 消息(prompt hooks often just want to append a synthetic user/assistant message);
- 现状是:这就意味着要伪造(fake)ID、时间戳和请求元数据。
第 3 条是当前实现的痛点所在。从源码结构看,这一痛点有直接证据:当前 v2 的会话消息统一由 session-message.ts 中的Session.Message联合类型承载,所有消息类型共享同一个Base结构:
const Base = { id: ID, // "msg_" 前缀的品牌化 ID metadata: ..., // 任意元数据 time: { created }, // 创建时间 }其中就包含专门用于注入合成文本的Synthetic消息类型:
export const Synthetic = Schema.Struct({ ...Base, // 必须携带 id、time.created 等完整"真实消息"字段 sessionID: SessionID, text: Schema.String, type: Schema.Literal("synthetic"), })也就是说,一个插件钩子如果只是想往提示词里插一句"总结上面工具的输出并继续",就必须构造一条带有 ID、时间戳、会话 ID 的完整持久化消息——这正是文档所说的"faking ids, timestamps"。而Assistant消息则同时承载了运行上下文(agent、model: Model.Ref)、结果(finish、error)、用量(cost、tokens)与快照(snapshot)等大量执行侧数据,使得"纯对话内容"与"执行元数据"混居在一条消息里。
设计文档的目标就是解决这一矛盾:让存储历史更干净,让提示词钩子更轻。以下三个方案按递进关系给出。
方案一:两种消息形态(Two Message Shapes)
方案一的核心思路是:User/Assistant继续作为存储历史的模型,但要把它们"清理干净";同时新增一个独立的、瞬态(transient)的PromptMessage专供提示词改写使用。
存储形态:精简后的 User / Assistant
type User = { role: "user" time: { created: number } request: { agent: string model: ModelRef variant?: string variant?: string format?: OutputFormat system?: string tools?: Record<string, boolean> } } type Assistant = { role: "assistant" run: { agent: string; model: ModelRef; path: { cwd: string; root: string } } usage: { cost: number; tokens: Tokens } result: { finish?: string; error?: Error; structured?: unknown; kind: "reply" | "summary" } }(注:以上代码完整继承自 message-shape.md 原文,User.request中未含variant之外的重复字段,User的request字段实际包含agent / model / variant? / format? / system? / tools?。)
与现状相比可以注意到几个关键变化:
执行设置收敛进
request子对象:agent、model、variant、format、system、tools归组为一次请求的完整描述。对照 session-message.ts 中的现状,Assistant目前把agent、model、finish、cost、tokens、error全部平铺在消息顶层,而方案一将其重组为run(运行时上下文:agent、model、工作目录路径)与usage/result(用量与结果)两个语义明确的块。model: ModelRef的落点有现成依据:v2 模型层已经定义了对应的引用结构,即 model.ts 中的Model.Ref:export const Ref = Schema.Struct({ id: ID, providerID: Provider.ID, variant: VariantID.pipe(optional), })规格中
ModelRef的{ providerID, modelID }风格(见方案三示例)与该Ref结构语义一致,说明"模型以引用而非内联对象进入消息"是 v2 既定的方向。Assistant的kind: "reply" | "summary"区分了普通回复与压缩摘要类消息,对应现状中 session-message.ts 里独立存在的Compaction消息类型——方案一将其内化为结果的一个维度。
瞬态形态:PromptMessage
type PromptMessage = { role: "user" | "assistant" parts: PromptPart[] }PromptMessage刻意没有 ID、没有时间戳、没有请求元数据——它只在提示词构造管道中存活,不进入存储历史。插件钩子的用法因此变得极其简单:
prompt.push({ role: "user", parts: [{ type: "text", text: "Summarize the tool output above and continue." }], })权衡(原文 Tradeoff):提示词钩子获得了轻量的消息形态,但代价是系统中从此存在两种消息形态,类型系统与序列化边界都要为这条分界线负责。
方案二:提示词修改器(Prompt Mutators)
方案二选择不引入第二种完整消息类型:User/Assistant仍是唯一的存储历史模型;提示词钩子不直接构造消息,而是由运行时提供一组提示词修改器(prompt mutators),由运行时把修改意图落到正确的消息上。
PromptEditor的完整 API 如下(继承自 message-shape.md):
type PromptEditor = { append(input: { role: "user" | "assistant"; parts: PromptPart[] }): void prepend(input: { role: "user" | "assistant"; parts: PromptPart[] }): void appendTo(target: "last-user" | "last-assistant", parts: PromptPart[]): void insertAfter(messageID: string, input: { role: "user" | "assistant"; parts: PromptPart[] }): void insertBefore(messageID: string, input: { role: "user" | "assistant"; parts: PromptPart[] }): void }五个方法覆盖了提示词改写的主要意图空间:
| 方法 | 语义 | 典型用途 |
|---|---|---|
append | 在末尾追加一条消息 | 在工具执行后追加合成的 user 指令 |
prepend | 在开头插入一条消息 | 前置全局约束/上下文 |
appendTo | 向"最后一条 user/assistant 消息"追加 parts | 向当前用户输入追加一段固定文本 |
insertAfter | 在指定消息 ID 之后插入 | 针对历史中特定轮次的定点注入 |
insertBefore | 在指定消息 ID 之前插入 | 同上,方向相反 |
插件钩子示例(文档原文的两个用例):
prompt.append({ role: "user", parts: [{ type: "text", text: "Summarize the tool output above and continue." }], })prompt.appendTo("last-user", [{ type: "text", text: BUILD_SWITCH }])第二个示例揭示了该方案的另一个价值点:向"最后一条用户消息"追加 parts 时,钩子不需要知道这条消息的 ID、时间戳,甚至不需要复制其内容——运行时通过messageID/ 目标选择器把 parts 合并进既有的存储消息,彻底回避了"伪造 ID 与时间戳"的问题。
权衡(原文 Tradeoff):避免了第二种完整消息类型,也避免了伪造 id/timestamp,但代价是把更多"魔法"(magic)挪进了钩子 API——钩子不再操作显式数据,而是操作一组有隐式定位语义的操作。
方案三:独立的 Turn 状态(Separate Turn State)
方案三更进一步:把执行设置整体移出User消息,放进独立的 turn/request 对象。消息只保留"对话内容 + 归属哪个 turn",执行配置作为一等公民单独建模。
type Turn = { id: string request: { agent: string model: ModelRef variant?: string format?: OutputFormat system?: string tools?: Record<string, boolean> } } type User = { role: "user" turnID: string time: { created: number } } type Assistant = { role: "assistant" turnID: string usage: { cost: number; tokens: Tokens } result: { finish?: string; error?: Error; structured?: unknown; kind: "reply" | "summary" } }文档给出的两个构造示例:
const turn = { request: { agent: "build", model: { providerID: "openai", modelID: "gpt-5" }, }, }const msg = { role: "user", turnID: turn.id, parts: [{ type: "text", text: "Summarize the tool output above and continue." }], }与方案一相比,方案三中User消息被压缩到了极致——role、turnID、time.created三个字段,存储体积与序列化成本都显著下降;而request块(agent/model/variant/format/system/tools)完整移入Turn,成为可独立查询、独立变更的执行配置。
权衡(原文 Tradeoff):存储消息变得非常小且干净,但重放(replay)时必须把消息与 turn 状态做 join,而且提示词钩子仍然需要一个机制来声明"我追加的内容归属于哪个 turn"。
三方案对比与源码佐证
把三个方案并置,可以得到如下对比("痛点"列引用文档原文 Tradeoff,"现状对照"列基于当前仓库源码):
| 方案 | 核心思想 | 存储消息体积 | 钩子复杂度 | 文档指出的代价 | 现状对照(源码证据) |
|---|---|---|---|---|---|
| 方案一 | 存储形态 + 瞬态PromptMessage双形态 | 中(request 归组进 User) | 低(直接 push 轻量消息) | 系统中存在两种消息形态 | 现状的Synthetic类型即"带完整 Base 的注入消息",PromptMessage是其去 ID 化版本 |
| 方案二 | 单一存储形态 +PromptEditor修改器 | 中(不变) | 中(API 有隐式定位语义) | 更多魔法进入钩子 API | 对应现状中AgentSwitched/ModelSwitched等"只改状态不发消息"的先例 |
| 方案三 | 执行配置抽离为Turn,消息仅持turnID | 最小 | 需指定 turn 归属 | replay 需要 join 消息与 turn 状态 | 现状Assistant顶层平铺的agent/model/cost/tokens正是被抽离的对象 |
从 session-message.ts 的整体结构可以进一步看出规格与实现的衔接关系:当前Session.Message联合类型已经包含AgentSwitched、ModelSwitched、User、Synthetic、System、Shell、Assistant、Compaction八种类型,说明"存储形态"正在向按事件/状态细粒度拆分的方向演进;规格文档讨论的三个方案,是在这一基础上继续回答"对话内容、执行配置、提示词改写三者如何分层"的问题。v2 API 侧的整体调用形态可参考同目录的 api.ts(session.create→session.prompt→session.messages),消息形态最终就是session.messages返回的持久化结构。
小结
这篇 v2 规格的价值不在于给出唯一答案,而在于把编码 Agent 会话存储中一条清晰的架构分界线摆了出来:
- 持久化历史(需要 ID、时间戳、用量、结果,供 replay/resume)与提示词改写(只需要 role + parts,供当轮提示词构造)不应共用同一个消息类型;
- 方案一用"类型分离"解决,方案二用"API 抽象"解决,方案三用"状态外置"解决,分别对应类型系统、运行时 API、存储模型三个层面的取舍。
对阅读 opencode v2 代码的开发者而言,理解这条分界线是读懂 packages/schema/src/session-message.ts 中消息模型演进、以及后续 prompt 钩子 API 设计的前提。
【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考