深入浅出 DeepSeek Harness 第八部:证明与守卫——怎样知道系统真的成立 第 51 章

Telemetry 是观测侧车,不是第二个会话引擎

你以为 Telemetry 采集了会话事件反馈统计,它就是一份"完整的会话记录",可以拿来恢复现场。错。DSH 的 telemetry 只拥有 CAPTURE 这一侧:默认 FEEDBACK_ONLY——只有用户显式提交反馈时,canonical log 才被回放一次上传;捕获走 session-telemetry/record 脱敏瀑布,服务自己一条规则都不带;MessageFeedback 用二进制正负评分加版本号乐观并发,永远只 append 到会话日志,从不创建或恢复 Session。

本章以中文撰写

源码版本
dsh-v0.1.7-rc.2
验证日期
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

第 0 秒,一个用户在 Web 界面上给某条助手回复点了”赞”,写了一句”这个重放说明很清楚”。这一刻系统里发生的事远比你以为的少:messageFeedback.put() 先把 note 过一遍 UTF-8 字节体检(空白 → note-blank,超长 → note-too-large),再翻完整份会话日志确认这条 assistant/message 事件真的存在(不存在 → target-not-found),然后核对你观察到的版本号(对不上 → version-conflict),全部通过才 append 一条 feedback/message-put 事件——到这里就结束了。它没有为你创建任何 Session,没有恢复任何 Agent,没有把这条反馈塞进模型的下一轮上下文。它只是在 append-only 日志上又钉了一条标注为”log-only”的事件。与此同时,这条反馈倒是可能触发一次上传:部署默认是 FEEDBACK_ONLY——只有用户显式提交了反馈,canonical log 才被回放一次交给 OTel 后端;没有反馈的会话,对话内容从不出本机。

你以为 Telemetry 是一份”完整的会话记录副本”,可以拿来重建现场。其实它是一辆侧车:它把路况报给车队电台,但方向盘、刹车、行车记录仪(canonical session log)全在主车上。

这一章接着第 50 章的”凭证不出日志”:那章讲最敏感的数据为什么在结构上就进不了日志;这一章讲日志另一端的问题——观测数据为什么永远不能反过来定义会话。

Telemetry 捕获侧全景:上半,Session firehose(session/created、session/event、session/disposed)喂给 SessionTelemetryCoordinator,live 捕获在 append 时逐条 captureEvent(body = structuredClone(event.data)),过 session-telemetry/record 脱敏瀑布(服务零规则、innermost next 原样放行)后交给 SessionTelemetryBackend.emit;ops 通道另有 agent/error 中继与 shutdown 标记。下半,MessageFeedback.put 路径:resolveNote → 目标 assistant/message 事件存在性(target-not-found)→ ifVersion 乐观并发(version-conflict)→ append feedback/message-put;live 路径 append 后重开 handle 做 header 三元组深等核对;默认 FEEDBACK_ONLY 模式下只有显式反馈事件授权回放上传,fork seed 不授权。

边界的第一刀:这个包只拥有 CAPTURE 一侧

读 @deepseek-ai/dsh-session-telemetry 包头部注释,第一句话就把边界钉死了:“This package owns the CAPTURE side of session-event reporting”——one-record-per-event 的镜像、记录携带什么、何时捕获(adoption、per-append firehose、lifecycle forwarding)、live 与 on-demand 的 canonical-log 捕获、HMR cursor,归它管;“Everything downstream of SessionTelemetryBackend.emit — batching, retry, queueing, and loss policy — is the reporting SDK’s territory and is deliberately not modelled here.”

这句话的设计含义是:捕获侧只管”忠实地把每一个事件变成一条逻辑记录交出去”,交出去之后排多少批、失败重试几次、队列满了丢哪条——那是后端 SDK 的职责,捕获侧不知道、也不关心。如果捕获侧连丢失策略都建模,它就变成了第二个会话引擎:自己决定哪些事件算数。

面CAPTURE 侧(本包)下游(reporting SDK / 后端)
职责每事件一条逻辑记录、脱敏瀑布、cursor 推进批处理、重试、排队、丢失策略、导出时机
状态handoffCursor(记录交到哪了)队列、批量定时器、导出连接
失败语义contain() 包住每一步,失败只 warn由 SDK 自己的退避与丢弃策略决定
与 agent loop 关系emit 必须是非阻塞入队同上——“anything slower than a queue push would tax the agent loop”

为什么要切这一刀? 因为捕获侧的任何”聪明”都可能变成会话的第二个事实源。如果捕获侧有权决定”这个事件先不发、合并下一条再发”,那导出的记录序列就和日志序列对不上——你拿到的遥测数据将无法与会话日志逐条对账。只 CAPTURE、不策略,是”镜像”这个词成立的前提:ledger 通道的记录与 session-log 事件一一对应,多一条少一条都是事故。

本节源码依据(3 处)

两种通道:ledger 镜像与 ops 信号

一条遥测记录长什么样?SessionTelemetryRecord 只有四个字段:channel、time、severity、attributes、body。但通道分两种,语义完全不同:

通道装什么身份字段例子
ledgersession-log 事件的一一镜像session.id、session.format_version、event.type、event.seq,可选 session.cwd/session.parent_id,seeded 会话带 session.seed_length每条 turn/event
ops无日志归宿的两个运维信号:agent-error 与 shutdowntelemetry.op、session.id,agent-error 另带 agent.id/turn/step/error.nameagent 崩溃、会话 disposal

关键细节在注释里:ops 记录**“deliberately omit event.seq-style identity so they can never be mistaken for ledger rows”**。运维信号不是日志事件,不给 seq——从结构上杜绝把一条 shutdown 标记当成会话日志里某一条。attributes 也刻意最小:“Anything recoverable from the body is intentionally NOT duplicated here”——能从 body 里推出来的身份,一个都不复制,防止两份事实漂移。

severity 是捕获时预烤好的:tool/result 的 isError === true → error;turn/end 的 reason.kind === 'error' → error;其余一律 info。注意默认分支不做 assertNever——插件合并进来的新事件类型它从没听说过,按 info 放行,“their owners’ outcome semantics stay theirs”。捕获侧不猜别人事件的严重级别,猜了就是替别人的业务下判断。

本节源码依据(4 处)

默认 FEEDBACK_ONLY:没有反馈,就没有上传

安全侧最反直觉的事实在 OTel 后端:部署默认模式是 FEEDBACK_ONLY,不是 full。SessionTelemetryMode 枚举只有两个值:FEEDBACK_ONLY 与 DISABLED,DEFAULT_TELEMETRY_MODE = FEEDBACK_ONLY。也就是说,开箱配置下,一个没有提交过任何反馈的会话,它的对话内容永远不会被回放上传。

isFeedback() 把授权条件写得很窄:

if (event.seq < session.inheritedEventCount) return false   // fork seed 不算
switch (event.type) {
  case 'feedback/record': return true
  case 'feedback/message-put':
  case 'feedback/message-delete': return event.data.sessionId === session.id
  default: return false
}

三道闸门:第一,fork 继承来的 seed 事件不算授权——你父会话被反馈过,不代表子会话也同意;第二,只认 feedback 三兄弟;第三,feedback/message-* 还必须落在本会话自己的 id 上(类型注释早就说了,“inherited feedback in a fork belongs to its parent”)。

模式会话内容上传构造 SDK 状态行为
DISABLED从不不构造只挂监听:记录了反馈却留在本地时警告一句”this feedback is not uploaded through OpenTelemetry”
FEEDBACK_ONLY(默认)仅在显式反馈事件触发时回放一次构造 on-demand coordinator + SDK 管道反馈落盘 → 回放 canonical log → 导出

为什么默认要收得这么紧? 因为遥测的价值分布是长尾的:出问题的会话才需要上传证据,绝大多数会话一辈子不需要。默认抓全量等于把用户每一句对话当作默认外发材料——这在隐私上是默认有罪。FEEDBACK_ONLY 把”上传”绑定在一个用户显式动作上:你点了赞、写了 note,这个动作本身就是同意信号,连带把日志带出去。不表态 = 不出厂。DISABLED 模式连 SDK 都不构造,只留一条警告监听,告诉你”反馈记了,但没传”。

还有一个独立的产品遥测包 product-telemetry-otel,它与会话遥测完全隔离:包头注释写明”Explicit product usage events over OTLP/HTTP; no automatic collection or Session access”,记录体要求”never a prompt, response, credential, or file contents”。产品埋点是显式事件,会话遥测是反馈授权回放——两条管道互不串门。

本节源码依据(5 处)

脱敏瀑布:服务一条规则都不带

捕获侧的另一个反直觉设计:session-telemetry/record 这个脱敏瀑布自身不发运任何规则。注释原话:“This waterfall is the Service Definition’s redaction extension point. It ships NO rules of its own: the innermost next() passes the record through unchanged, and with no listener mounted records reach the backend as captured, so exported data is exactly as clean as the rules a deployment mounts.”

三个执行细节同样关键:

  1. 同步跑在捕获热路径上:live 捕获在 append 时 dispatch,on-demand 捕获在读日志时 dispatch;
  2. 抛错即扣留:“a throwing listener withholds that one record (fail-closed) and never reaches the agent loop”——脱敏规则写崩了,这条记录宁可不上传,也不让脏数据出厂,更不让异常炸进 agent loop;
  3. 只改导出副本:“Redaction applies to the exported copy only; the canonical session log is never rewritten.”

为什么服务不内置规则? 因为”什么该脱敏”是部署策略,不是产品事实。北京的部署和欧洲的部署要遮的字段不同;内置一份规则清单,要么永远追不上合规要求,要么把某些部署不敏感的字段白白遮掉。服务的正确姿势是把扩展点钉死(waterfall 契约 + fail-closed 语义),把规则数量保持为零——导出数据的干净程度,如实等于部署挂载的规则数,这句话写在注释里,是承诺不是借口。

本节源码依据(3 处)

live 与 on-demand:两种捕获节奏,一个 cursor

coordinator 的两种模式对应两种部署姿态。live 捕获挂上一整套监听:session/created → adopt、session/event → captureEvent、session/disposed → 补 shutdown 记录并摘出接管集、session/flush → 给后端一个 flush 提示、外加 agent/error 总线中继为 ops 记录。构造时还会sweep 已经活着的会话——热重载不会补发 session/created,不扫一遍新 fiber 就漏了存量。

on-demand 捕获什么持续监听都不挂,captureSession(session, throughSeq?) 由调用方显式驱动:从 handoff cursor+1 开始把 canonical log 逐条过一遍。

维度liveon-demand
监听created/event/disposed/flush + agent/error无
历史adopt 时沿 cursor 补拍调用时显式 replay
ops 记录disposal 边缘发 shutdown不产生
典型部署常开遥测FEEDBACK_ONLY 反馈触发

cursor 本身是一个模块级 WeakMap<Session, SessionSeqCursor>——注释承认这是对”registrations-are-effects”纪律的刻意破例:cordis 没有 HMR 状态移交 API,只有按 Session 对象键才能让热重载后的新 fiber 续传而不是重发全部历史。“Entries die with their sessions; a missing entry safely means ‘re-hand everything’“——cursor 丢了的安全语义是全量重发,不是漏发。

为什么 per-event 独立 contain? 注释写得明白:“Containment is PER EVENT: one rejected record is withheld fail-closed while the rest of the historical replay proceeds.” 回放一段三天前的日志,第 412 条记录脱敏抛错——扣住那一条,后面照跑。错误隔离到单条,历史回放才不会一条毒丸毒化整场导出。

本节源码依据(4 处)

MessageFeedback:侧车的另一面,五条拒绝边

现在看反馈本体。MessageFeedbackService 的类注释一句话定调:“Session-log service; cold operations never construct a Session or Agent.”——冷路径连 Session 或 Agent 对象都不构造,它只是日志的读者与追加者。

打一次分(put)要过五道口,任何一道不过都是显式业务失败,而不是模糊异常:

失败码触发条件消息体
note-blanknote 去掉空白后为空纯码,无字段
note-too-largenote 的 UTF-8 字节数 > maxNoteBytes带 maxBytes 与 actualBytes
session-not-foundlive store 与持久化里都查无此会话带 sessionId
target-not-found日志里没有匹配 id 的 assistant/message append 事件带 sessionId + messageId
version-conflict请求 ifVersion 与当前 item 版本不一致带当前权威 item 快照(或 null)

几个值得钉住的细节:

评分是二元的。 rating: z.enum(['positive', 'negative'])——没有一星到五星。用户判断只有”有用/没用”,细分交给 7 个 category(task-result、instruction-following、product-interaction、service-stability、resource-cost、security-privacy-permission、other)。为什么二元?因为评分要喂的是回归筛选,不是满意度排行榜;二元 + 分类的信噪比远高于模糊星级。

目标存在性是硬门槛。 put 之前要遍历日志找 assistant/message 的 append-surface 事件,deriveEventMessage(event)?.id === request.messageId 才算数——给一条日志里不存在的消息打分,直接 target-not-found。反馈表不会凭空为你创造一个 message 来匹配评分。

乐观并发是 CAS 不是覆盖。 每次物化写入换一个新的 randomUUID 版本号;请求带着观察到的旧版本号来,对不上就是 version-conflict,并把当前权威快照回给你——重读、再看、重打。两个客户端同时给同一条消息打分,后写者必败,这是正确性保证,不是性能优化。

值相同就是 no-op。 rating/note/category 全部相等时,append 一个空事件(append() 不带 event)然后原样返回——不产生新事件,版本号保留。重复点同一条赞不会把日志灌成垃圾。

live 路径要做耐久性反查。 活跃会话 append 并 flush 之后,它不信任”flush 有 listener 参与”这一条——重开持久化 handle 读回最后一条,把 header 三元组 [id, createdAt, cwd] 与 live header 做深等,且读回事件与内存最后一条深等;任何一项不等就抛”feedback prefix is not durable”。注意这里的身份核对是 header 三元组,不是 sessionId 字符串——旧版手册里”sameIdentity 函数”的说法在这个 commit 上不存在,实际是这段耐久性反查里的 isDeepStrictEqual。

每会话串行化。 operationTails 按 sessionId 把完整读-改-写排成链;服务处置期间新写入直接拒收”service is disposing”。串行化的理由是反馈操作横跨 live 与 cold 两条路径,不排队就会在持久性反查时读到自己插进来的中间态。

最后,反馈事件的性质由类型注释钉死:“Log-only human feedback; never enters model history”,删除时”earlier ratings and notes remain in the log”。你打的每一次分、写的每一句 note,模型一轮都看不见;删掉也只是加一条 delete 事件,历史留在 append-only 日志里。

本节源码依据(9 处)

边界总表:观测能证明什么,不能证明什么

把这一章的所有机制压成一张边界表:

问题观测数据的回答为什么
用户给某条消息打了好评吗能——feedback/message-put 事件是显式动作rating 是用户点出来的
用户读了那条消息吗不能——没打分不等于没读没有”已读”信号
模型输出正确吗不能——好评是主观判断主观 ≠ 客观事实
会话完整历史是什么不能——telemetry 只镜像事件结构,且默认不外发恢复必须走 canonical log 重放(第 52 章)
note 里的内容保密吗不保证——明文 UTF-8 落盘,无任何加密逻辑系统明确不承诺 note 机密性,别写密钥

为什么这条边界必须如此诚实? 因为观测系统最大的诱惑是”既然我都记了,顺便拿来当数据源吧”。一旦反馈库开始被用来恢复会话、训练模型、还原现场,它就背上了自己永远扛不动的责任——评分会变、note 会删、版本会冲突。把它永远锁在”侧车”位置:append 到日志、不进历史、不造会话、默认不外发,它才可以安全地一直便宜下去。

小结

  • 捕获侧只管 CAPTURE:每事件一条记录、一一镜像;批处理、重试、丢策略全是后端 SDK 的事。捕获侧越”聪明”,越可能变成第二个事实源。
  • 默认 FEEDBACK_ONLY 是隐私姿态:没有显式反馈,对话内容一次都不出本机;fork seed 不授权回放。上传绑定在用户动作上。
  • 脱敏瀑布自身零规则:导出数据的干净程度 = 部署挂载的规则数;规则抛错 = fail-closed 扣留该条。
  • 反馈是日志事件不是历史:二元 rating + 七分类 + 可选 note;五条拒绝边(note-blank/note-too-large/session-not-found/target-not-found/version-conflict)全部是显式业务失败。
  • 耐久性靠反查不靠信任:live append 后重开 handle 深等 header 三元组;每会话串行化读改写。

下一章是这条链的下一环:观测能告诉你”发生了什么记录”,但历史本身要怎样被查、被重放验证——第 52 章讲 SessionQueryEngine。

只读验证

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
git -C "$repo" rev-parse HEAD   # 应为 477b4f420553e8a52c2fbccc464d7561b239c443
grep -n "never enters model history" "$repo/packages/feedback/message-feedback/src/types.ts"
grep -n "DEFAULT_TELEMETRY_MODE" "$repo/packages/session/session-telemetry-otel/src/index.ts"
grep -n "target-not-found\|version-conflict" "$repo/packages/feedback/message-feedback/src/index.ts"