Files
deepseek-harness/.agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.zh.md

6.3 KiB
Raw Blame History

Agent Note: 将每条消息创建为带标识的不可变值

Status: implemented

English | 中文

问题

harness 曾存在多种形似消息的表示,各自采用不同的标识规则。agent(智能体)输入只有在 loop 接受后才会取得 inbox 关联 id,而持久用户消息、assistant 消息、工具结果和模型请求消息都可能没有标识。因此,提示词准入介于创建消息与建立标识之间;等价内容会在实时事件、持久事件和模型请求之间复制,却没有一个值能在消息的整个生命周期中标识它。

这使标识成为路由的副作用,而不是消息不变量。生产方无法在调用 agent 前引用一条消息,提示词钩子会分别接收内容和来源,后续投影则必须一边重建消息,一边决定 id 是否存在。不可变性也从不同边界开始:部分输入由 loop 冻结,部分直到会话追加时才冻结,提供方产生的 assistant 输出则使用另一种携带溯源信息的形状。

决策

@deepseek-ai/dsh-llm 持有唯一一种 Message 值,其 id、role、content 和 source 均为必填。MessageId 是不透明标识,由用户消息、assistant 消息和工具结果消息共享。消息在创建时就会获得 id,早于路由、提示词准入、持久追加或请求投影。同一个 id 会跨越每个表示边界。

createMessage(input) 是角色通用的规范创建边界。它会生成 MessageId,将输入的角色、内容和来源与输入分离,并在返回完整值前将其深度冻结。createUserMessage({ content, source }) 为提示词和上下文生产方固定 user 角色。createAssistantMessage({ content, source }) 同时固定 assistant 角色与模型来源类别,因此模型输出生产方只需提供内容和模型溯源信息。所有创建辅助函数的输入都不包含 id,因此调用方不会意外地把创建表达为导入。freezeMessage(message) 是独立的导入或转换边界:它会将已有标识的消息与输入分离并深度冻结,不会生成替代标识。

这些辅助函数位于基础消息词汇旁的 dsh-llm 中,因为它们的完整契约只依赖该词汇。createToolResultMessage() 与其他创建辅助函数同属此处:它使用同一个工具调用 id,将工具来源与确切的 user-role 工具结果块耦合起来,不依赖会话状态或事件。dsh-session 只消费完整消息,不负责构造它们。

Agent 接口接收完整的 UserMessage。send、followup、steer 和 inject 绝不会分配或返回标识;它们会冻结导入的值,而调用方已经持有该值的 id。提示词准入会直接接收该消息。改写内容时会创建具有相同 id 的冻结替代值,而每个附加上下文都是单独创建的 UserMessage,拥有自己的 id。

产生持久消息的事件会存储完整消息。user/message 直接存储其 UserMessage;assistant/message、tool/result 和 steering/message 则将各自角色专用的消息与事件本地的位置、用量、失败或呈现事实包装在一起。会话派生会返回这些冻结值,而不是重建匿名消息。assistant 组装会在响应完成时创建模型来源的消息,工具执行会在提交结果时创建工具来源的消息。

仅改变已有语义消息表示的操作会保留其 id,并返回另一个冻结值。创建新语义消息的操作则会生成新 id。因此,压缩(compaction)内容改写会保留被改写工具结果的标识,而摘要检查点是一条新消息。

考虑过的替代方案

让基础消息的 id 保持可选。 这能减少 fixture(测试前置数据)迁移,并允许提供方或持久化形状继续保持匿名,但也会保留原有歧义:每个消费方都必须根据标识是否存在执行分支,且没有任何类型能证明准入、记录或投影保留了标识。

让 Agent.send() 分配 id。 这会将标识限定在 inbox 关联范围内,却也会让 agent 调用成为生产方可以标识自身消息的最早时机。这样一来,在 send() 返回前,提示词构造、UI 附件和同步入队/丢弃协调都需要进行内容匹配,或使用带外 token。

让每个持久事件分配新 id。 这能为持久消息提供标识,却会有意切断它与实时输入的关联,并让回放请求表现得像包含了不同消息。标识属于语义值,而不是承载它的每个封装。

只在 agent 或会话准入时冻结。 这能省去创建辅助函数,却会留下一个带标识但可变的时间区间,调用方代码可以在这段时间内改变该 id 所关联的含义。本决策让「拥有 id」与「是不可变快照」同时成立。

后果

每个消息生产方都必须显式选择创建或导入,测试也会构造完整值,而不是不完整的内容/来源记录。UUID 的生成会前移至最初的语义创建点,因此提供已有 id 的确定性 fixture 会使用 freezeMessage(),而不是 createMessage()。

实时 inbox 事件、持久事件、派生历史和模型请求可以关联同一条消息,无需比较内容或使用封装专用 id。提示词准入和 UI 附件清理可以在轮次存在之前比较 MessageId。深度冻结可以防止生产方、钩子或观察方在标识建立后更改消息值。

共享表示移除了旧的 UserMessageData/AgentMessage 划分,并将提供方溯源信息纳入带类型的消息来源。事件封装仍持有不属于消息语义的事实,例如轮次与步骤位置、token 用量、内部工具失败标识和呈现元数据。

消息和辅助函数的单元测试会固定即时标识、输入分离、深度不可变性,以及导入 id 的保留。agent loop 测试会固定标识跨越准入、inbox 生命周期、持久追加、内容改写和取消的行为;会话测试会固定冻结派生和保留标识的替换行为。

相关