青雲的博客

第八部:证明与守卫——怎样知道系统真的成立

写完了所有运行时模块,最后要回答一个工程师终极问题:你怎么知道它真的成立?从credentials不进日志到telemetry侧车定位,从trajectory replay到mock snapshot测试,从per-package invariant守卫到分层安全模型,最后这本书本身的更新方法论——这一部讲"证明"这件事。

[图片占位:一座堡垒,护城河(sandbox)、城墙(permission)、瞭望塔(telemetry)、守卫(invariant)、城门检查(credentials)各层防御。工程师拿着检查清单巡逻。顶部有旗帜写着VERIFIED。色调:堡垒灰色+金色守卫+蓝绿色护城河光。]
展开阅读路线与实验入口

你读完了前七部,知道CLI怎么启动、Cordis怎么组装、Turn怎么跑、工具怎么调度、事件怎么落盘。你甚至能画出整个系统的调用图。但有个问题你没法回答:

你怎么知道它真的成立?

密钥会不会泄漏到日志里?Telemetry采集会不会反过来污染会话?崩溃后能不能重放验证?测试是不是真的覆盖了真实路径?每个包自己的守卫是什么?安全是不是一层纸糊的墙?还有——这本书本身,当代码commit前进时,怎么保证你读到的不是过期的谎言?

这一部不引入新功能。这一部讲”证明”。

这一部解决什么

读完这七章,你能回答七个证明相关的问题:

  • 为什么credentials用引用而非值传递,空字符串为什么永远不被当作已配置密钥
  • 为什么telemetry/feedback只是观测侧车,它永远不能创建或恢复会话
  • SessionQueryEngine怎样做重放验证,traceEvent怎样追踪事件替换链
  • ACP Snapshot Harness怎样启动真实子进程做record/replay,22种LLM mock行为覆盖哪些故障
  • CI gate怎样强制每个包必须有自己的invariant.ts companion
  • 安全为什么是Trust→Permission→Sandbox→Network四层,每层都不假设其他层完美
  • 这本书本身的更新方法论:为什么锁commit不是为了停在旧版,而是让每个”当前”有可复核对象

你可能以为”证明”就是写测试。Harness不这么认为。测试是证明的一部分,但不是全部。证明还包括:密钥不泄漏的设计约束、观测不写入的边界、重放验证的能力、每个包自带守卫的CI强制、分层防御不把鸡蛋放一个篮子里、以及文档本身的可验证性。

accTitle: 第八部阅读路径
accDescr: 凭证不出日志、Telemetry 全链路捕获、Trajectory 可查询回放、Mock Snapshot 测试、Invariant 目录文档化、Trust/Permission/Sandbox/Network 四层安全、Update 固定快照保证可复现
accDescription: 第八部阅读路径流程图,从 Credentials(引用非值+0600权限+不进日志)开始,经过 Telemetry(侧车定位:只观测不创建)、Trajectory/Query/Replay(readSession做重放验证)、Mock Snapshot(真实子进程+22种LLM故障注入)、Invariant Catalog(每个包有自己的守卫,CI强制)、安全分层(Trust→Permission→Sandbox→Network),最后到更新方法论(锁commit不是停在旧版)。
flowchart TD
    A["Credentials\n引用非值 + 0600权限 + 不进日志"] --> B["Telemetry\n侧车定位:只观测不创建"]
    B --> C["Trajectory/Query/Replay\nreadSession做重放验证"]
    C --> D["Mock Snapshot\n真实子进程 + 22种LLM故障注入"]
    D --> E["Invariant Catalog\n每个包有自己的守卫,CI强制"]
    E --> F["安全分层\nTrust→Permission→Sandbox→Network"]
    F --> G["更新方法论\n锁commit不是停在旧版"]
    style A fill:#1a3a5c,stroke:#d4af37,color:#fff
    style G fill:#1a3a5c,stroke:#d4af37,color:#fff

所有证据来自固定commit的源码。实验命令只读不写,用$DSH_SOURCE_DIR指向官方checkout。

从最敏感的credentials开始。你会发现,防止密钥泄漏不是靠”小心打印日志”,而是从数据结构设计上就让密钥值根本不可能出现在错误消息里。

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

凭证隔离、Telemetry 与 Mock 测试

三项运维级关切——凭证为什么永远不会出现在日志里、Telemetry 如何无需定义 session 就完成采集、MockLlm 与快照测试如何让你确定性地重放 LLM 行为——在源码中究竟怎样实现。

源码版本
47f943859bef60e4160492346772ded9b24f765a
验证日期
Commit
47f943859bef60e4160492346772ded9b24f765a

引言:三项隐形守卫

你在 Harness 中调试 LLM 调用时,从未在日志或报错中看到过 API Key 的明文——这不是偶然的”别打印就好”,而是 结构性不可能。你打开 telemetry 开关后,会话事件几乎零延迟地到达 OTLP 收集器——但你从未在 agent 循环的任何位置声明过 session 或配置过 span。你写回归测试时发现不需要真正的 DeepSeek endpoint——一个种子可控的 mock 服务器逐请求消费脚本,确定性地注入所有失败模式。

下面按这三条线直接看源码:凭证怎样被挡在日志外,Telemetry 怎样只观察不介入,Mock Server 又怎样把失败路径做成可复现的测试输入。


第一节:凭证隔离——永远触碰不到日志的秘密

1.1 Schema 级 role(‘secret’) 声明

Harness 的 Settings 系统基于 schemastery 类型 schema。任何字段标记为 .role('secret') 后,在描述符序列化(describe()redactSecrets: true)或跨 wire 边界传输时,都会被 结构化剥离

function walk(node, value, path, secrets) {
  if (node.meta?.role === 'secret') {
    secrets.push({ path, set: value !== undefined })
    return undefined
  }
  // ...递归 object / dict / array
}

关键点:

  1. 剥离发生在序列化时刻,而不是”打印前字符串替换”——secret 字段的值从未进入输出结构。
  2. sidecar 记录:每个被剥离的位置记入 RedactedSecret[](包含 pathset 两个字段),让 UI 表单知道该字段的 slot 存在、是否已填写,但永远不接收实际值。
  3. 不可变输入redactSecrets() 从不修改原始对象——它构建一个新的 detached copy。

1.2 递归容器的完整覆盖

walker 处理三种容器类型:

  • object:按 schema dict 中声明的属性递归,未声明的属性原样保留。
  • dict:对值中每个 key 都用 node.inner 递归。
  • array:对每个元素用 node.inner 递归。
  • default(fail-open TODO):union、intersection、transform 中埋藏的 secret 会被原样返回——源码标注了 TODO(settings-wire-redaction) 承诺将来 fail-closed。

这意味着:你只要用标准 z.object() / z.dict() / z.array() 建模,secret 就不可能穿越到 wire 描述符。

1.3 assertUsableApiKey:错误消息绝不回显凭证

在 LLM 包的 API Key 校验路径中,assertUsableApiKey() 拒绝非法 key 时 从不把原始值放进错误消息

throw new LlmError(
  checked.reason === 'empty'
    ? `${pkg}: the API key resolved from ${ref} is blank; set ${ref} to the raw key`
    : `${pkg}: the API key resolved from ${ref} contains characters no HTTP header can carry;`
      + ` set ${ref} to the raw key alone`,
  INVALID_CREDENTIAL_CODE,
)

你可以看到:

  • 错误消息引用 环境变量名ref)和 包名pkg),不是 key 本身。
  • 测试显式验证这一点:expect((error as Error).message).not.toContain('supersecret')

1.4 normalizeApiKey:printable-ASCII 传输不变式

const LEGAL_API_KEY = /^[\x21-\x7E]+$/

export function normalizeApiKey(raw: string): ApiKeyCheck {
  const value = raw.trim()
  if (value.length === 0) return { ok: false, reason: 'empty' }
  if (!LEGAL_API_KEY.test(value)) return { ok: false, reason: 'illegalCharacters' }
  return { ok: true, value }
}

这是一个 传输不变式(transport invariant):任何不在 !~ 范围内的字符都无法被 HTTP header 承载——所以提前拒绝是帮用户避免后续 opaque 401,而不是某个 provider 的特殊政策。

1.5 环境变量即唯一存储

cordis.patch.yml 中的凭证配置通过 !!js process.env.DSH_TELEMETRY_OTLP_URL 式环境变量表达式注入。Settings schema 中 role('credential-ref') 标记的字段存的是 引用名(如 DEEPSEEK_API_KEY),不是值本身。解析到值只发生在运行时内存中——文件系统上永远只有名字。


第二节:Telemetry——无需声明 Session 的事件采集

2.1 架构分层

Telemetry 系统分为三层:

职责
Service Definitiondsh-session-telemetry定义 SessionTelemetryBackend 抽象类、SessionTelemetryRecord 结构、SessionTelemetrySink 接口
Coordinatordsh-session-telemetry/coordinator采集逻辑——live/on-demand 两种模式、chunk projection、redaction waterfall
Backenddsh-session-telemetry-otelOTel SDK 集成——LoggerProvider + BatchLogRecordProcessor + OTLP/HTTP exporter

2.2 Coordinator:live 模式的零配置采集

当 backend 以 FULL 模式加载时,Coordinator 在构造函数里直接订阅四个事件:

ctx.on('session/created', (session) => this.adopt(session))
ctx.on('session/disposed', (session) => { /* shutdown marker + retire */ })
ctx.on('session/event', (session, event) => this.captureEvent(session, event))
ctx.on('session/flush', (session) => this.hintFlush(session))
ctx.on('agent/error', ({ agent, turn, step, error }) => this.relayAgentError(...))

你从未在 agent 循环中调用过任何 telemetry API——session/event bus 在 Session.append() 时自动 emit,Coordinator 作为 subscriber 自动捕获。

2.3 Chunk Projection:只运第一块

对于 assistant/chunk 事件,Coordinator 只交付每个 (turn, step)第一块——“stream-started” 信号。后续 chunk 的内容在步骤结束时会通过 assembled assistant/message 完整交付,所以重复 chunk 既浪费带宽也无信息增益。

if (event.type === 'assistant/chunk') {
  const key = `${event.data.turn}:${event.data.step}`
  if (seen.has(key)) return  // 丢弃后续 chunk
  seen.add(key)
}

2.4 Redaction Waterfall:导出前最后一道防线

session-telemetry/record 是一个 cordis waterfall 事件。部署方可以挂载任意数量的 listener 来脱敏导出记录:

  • innermost next() 原样返回 record。
  • 每层 listener 可以变换 next() 的返回值。
  • 跳过 next() = 完全替换下层所有处理。
  • 抛异常 = fail-closed,该条 record 被扣留,永远不到达 backend。

核心安全属性:canonical session log 永远不会被改写——redaction 只作用于导出副本。

2.5 OTel Backend:从 record 到 OTLP/HTTP

Backend 把每条 SessionTelemetryRecord 映射为一次 OTel logger.emit() 调用:

const enqueue: SessionTelemetrySink['emit'] = (record) => {
  const logger = record.channel === 'ops' ? ops : ledger
  logger.emit({
    timestamp: record.time,
    observedTimestamp: record.time,
    ...SEVERITY[record.severity],
    body: record.body as AnyValue,
    attributes: record.attributes,
  })
}

之后的 batching、retry、queueing 全部由 OTel SDK 的 BatchLogRecordProcessor 管理——Harness 代码不重新实现任何导出逻辑。

2.6 DSH_TELEMETRY_MODE 与 DISABLED 默认

cordis.patch.yml 配置:

mode: !!js process.env.DSH_TELEMETRY_MODE || 'DISABLED'

三种模式:

  • DISABLED(默认):不创建 SDK 状态,只在有人提交 feedback 时 warn “stays local”。
  • FEEDBACK_ONLY:只有当 session 中出现 feedback/record 事件时,才回溯该 session 的 canonical log 做一次 on-demand capture。
  • FULL:实时 firehose 采集所有 session 事件。

DSH_TELEMETRY_DISABLED(任意非空值)由 launcher 注入来强制 patch 行禁用——这是进程级 opt-out。

2.7 Shutdown 与 drain 时限

async shutdown(): Promise<void> {
  if (this.provider === undefined) return  // DISABLED 无 SDK 状态
  const providerShutdown = this.provider.shutdown()
  let timer: ReturnType<typeof setTimeout> | undefined
  const deadline = new Promise<never>((_resolve, reject) => {
    timer = setTimeout(() => {
      reject(new Error(`provider shutdown exceeded ${this.shutdownTimeoutMillis}ms`))
    }, this.shutdownTimeoutMillis)
  })
  try {
    await Promise.race([providerShutdown, deadline])
  } finally {
    if (timer !== undefined) clearTimeout(timer)
  }
}

默认 3000ms。OTel SDK 的 forceFlush() 可能在 transport 无法获取 socket 时永远 pending——所以 backend 用外层 deadline 做 load-bearing 的超时守卫。finally 块确保 timer 总被清理,避免 Node 进程因悬挂 timer 无法退出。Provider promise 在 deadline reject 后仍被 observed(不 .catch()),确保潜在的后续 rejection 不变为 unhandled。


第三节:Mock LLM 与确定性测试

3.1 llm-mock-server:脚本化行为序列

startMockLlmServer() 启动一个本地 HTTP/SSE 服务器,接受一个有序 sequence:每次 /chat/completions 请求消费序列中的下一个行为。26 种行为覆盖:

  • 传输层失败connection_resetstream_disconnectpartial_disconnectstall
  • 协议层异常malformed_jsonmalformed_eventwrong_content_typeempty_bodystream_eofpartial_eof
  • HTTP 错误rate_limit(429)、server_error(500)、service_unavailable(503)、auth_error(401)、invalid_request(400)、context_overflow(400)、quota_exceeded(429)
  • 成功路径successslow_successreasoning_successtool_call_successmax_tokensempty
  • 随机random——按配置的权重表在上述具体行为中选择。

3.2 种子随机与确定性重放

function seededRandom(seed: number): () => number {
  let state = seed
  return () => {
    state = (state + 0x6d2b_79f5) >>> 0
    let mixed = state
    mixed = Math.imul(mixed ^ mixed >>> 15, mixed | 1)
    mixed ^= mixed + Math.imul(mixed ^ mixed >>> 7, mixed | 61)
    return ((mixed ^ mixed >>> 14) >>> 0) / 0x1_0000_0000
  }
}

randomSeed 可以显式指定或自动生成后暴露在 MockLlmServer.randomSeed 上——测试失败时,用记录的种子精确重现同一行为序列。权重通过 DEFAULT_MOCK_LLM_RANDOM_WEIGHTS 配置,以非均匀概率模拟生产压力分布。

3.3 请求录制:完整 wire 证据

每个接受的请求都记录为 MockLlmRequestRecord

interface MockLlmRequestRecord {
  readonly attempt: number
  readonly scriptBehavior: MockLlmBehavior | 'script_exhausted'
  readonly behavior: ConcreteMockLlmBehavior | 'script_exhausted'
  readonly path: string
  readonly headers: Readonly<IncomingHttpHeaders>
  readonly body: unknown
  chunksSent: number
  outcome?: MockLlmRequestOutcome
}

测试可以对 server.requests 做断言——验证重试次数、header 内容、request body 格式,以及每次请求的最终 outcome(completedresetstalledclient_closedserver_error)。

3.4 repeatLast 与 script_exhausted

sequence 耗尽时:

  • repeatLast: true:无限重复最后一个行为(适合 stress test)。
  • repeatLast: false(默认):返回 500 + MOCK_SCRIPT_EXHAUSTED——loud failure,让你立刻发现测试消耗了比预期更多的请求。

3.5 Bearer Token 验证

if (resolved.apiKey !== undefined &&
    request.headers.authorization !== `Bearer ${resolved.apiKey}`) {
  response.writeHead(401, { 'content-type': 'application/json' })
  response.end(JSON.stringify({
    error: { message: 'invalid mock bearer token', code: 'invalid_api_key' }
  }))
  return
}

如果 mock server 配置了 apiKey,则每个请求必须携带匹配的 Bearer token——这让你验证 adapter 是否正确把凭证放进了 Authorization header,而不需要连接真正的 provider。省略 apiKey 配置则接受任何 authorization header,适合只关心 response 行为的测试场景。

3.6 SSE 流模拟与 chunk 切割

splitText() 按 Unicode code-point(不是 byte)切割 response text,每个 chunk 通过 writeSse() 发送为一个 data: 事件。slow_success 在 chunk 间注入 chunkDelayMs 延迟,模拟慢速流。partial_disconnect 在发送部分 chunk 后主动 response.destroy(),触发客户端的 stream 恢复逻辑。

3.7 Telemetry 观察者(onEvent)

function emit(options, event) {
  try { options.onEvent?.(Object.freeze(event)) }
  catch (_) { /* observer failure never affects wire behavior */ }
}

测试可以通过 onEvent 回调收集 server 端的 telemetry 事件——但 observer 异常被 严格隔离,永远不会影响 mock server 对客户端的行为。这是观测性与行为正确性的分离原则。


第四节:三者如何协作

4.1 凭证 + Telemetry

Settings 的 describe({ redactSecrets: true }) 确保 wire 描述符中不含 secret;Telemetry 的 session-telemetry/record waterfall 是导出前的第二道脱敏屏障。两道防线独立运作:即使没有人挂 waterfall listener,secret 字段也不会通过 settings 描述符泄漏;即使 settings 不标记 role(‘secret’),部署方仍可通过 waterfall rule 过滤任何敏感内容。

4.2 凭证 + Mock 测试

mock server 的 apiKey 配置让你 不用真正凭证 就能完整测试 authorization 路径。assertUsableApiKey 的错误消息不回显凭证这一属性也被单元测试显式守卫——回归保证。

4.3 Telemetry + Mock 测试

OTel backend 的单元测试使用一个本地 node:http mock collector(不是 llm-mock-server,而是更简单的 request-capture server)验证导出格式。Session telemetry 的 coordinator 测试则用 CollectingBackend(一个 3 行的 in-memory sink),完全不依赖 OTel SDK——关注点隔离。


第五节:不变式与防御姿态

不变式守卫者违反时行为
secret 值不跨 wireredactSecrets() walker返回 undefined,记录 path
错误消息不含凭证原文assertUsableApiKey只引用 ref 名
telemetry 异常不逃逸到 agent loopCoordinator contain()catch + warn
waterfall 异常 = fail-closedCoordinator captureEventrecord 扣留不送 backend
shutdown 有确定性上界Backend Promise.race3s deadline reject
mock script 耗尽 = loud 500selectBehavior()返回 script_exhausted
observer 异常不影响 mock wireemit() try/catch静默吞错

第六节:配置速查

Telemetry 环境变量

变量作用默认
DSH_TELEMETRY_MODEFULL / FEEDBACK_ONLY / DISABLEDDISABLED
DSH_TELEMETRY_OTLP_URLOTLP/HTTP logs endpointhttps://harness-telemetry.deepseeksvc.com/v1/logs
DSH_TELEMETRY_DISABLED任意非空值 = 进程级 opt-out未设置

Mock Server 关键参数

参数作用默认
sequence行为脚本(必填)
repeatLast耗尽后循环最后一个false
randomSeed确定性种子OS 随机
chunkSize每 SSE delta 的 code-point 数8
chunkDelayMsslow_success 的 inter-chunk 延迟25ms
disconnectDelayMs断连前等待10ms

收口

到这里,这章想守住的三条边界就很清楚了:

  1. 凭证隔离不是约定,是结构——schema walker 在序列化那一刻就把 secret 扔掉,error 构造函数只引用名字,测试里还会显式断言”不含原文”。
  2. Telemetry 是被动采集,不是主动声明——Coordinator 订阅 session bus,chunk projection 负责降噪,waterfall 负责可扩展脱敏,OTel SDK 负责真正把数据送出去。
  3. Mock 测试要的是确定性回放——26 种行为覆盖失败模式,seed 保证可重现,wire 录制让断言精确到 header。

有了这三道护栏,你就能把三件事分开做:开发期不泄漏凭证,生产期采集但不侵入 agent 循环,CI 里不连真实 endpoint 也能把恢复路径跑全。