BodySense Diagnosis 的 Durable Domain Model

把 DiagnosisAnalysis、Candidate、Hypothesis、Evidence、EvidenceGap 与 EvidenceAttempt 按 durable identity、生命周期和 epistemic role 分层,解释为什么历史诊断应 immutable 而不是被后续健康状态改写。

#type / synthesis #status / growing #tech / ai #tech / architecture #tech / dev / backend #resource / bodysense #resource / agent

[!info] related notes

BodySense Diagnosis 的 Durable Domain Model

范围

这篇笔记回答一个领域建模问题:

一次 Agent run 里产生的很多东西,哪些只是临时执行状态,哪些应该成为 BodySense 可长期引用、审计和 replay 的 durable domain artifact?

它不讨论 ORM 细节,而讨论 identity、生命周期和 epistemic ownership。

为什么 Agent 项目特别容易把 Runtime Object 误当 Domain Object

框架会天然暴露很多对象:

AgentRunResult
ModelRequest
ModelResponse
ToolCallPart
ToolReturnPart
LangGraph State
checkpoint
retry counter

这些对象因为“代码里非常显眼”,很容易被直接存进数据库,最终变成所谓领域模型。

但判断一个对象是否属于 domain,不应该看它是不是框架返回值,而应该问:

未来即使换了 PydanticAI / LangGraph / Provider,
业务还需要用这个概念解释世界吗?

如果答案是“是”,它才更可能值得稳定建模。

例如:

“当时缺少哪条关键证据?”

这个问题未来仍然存在,所以 EvidenceGap 是稳定业务语义。

而:

“PydanticAI 当时用了哪个 ToolReturnPart subclass?”

通常只是框架细节。

最重要的第一刀:Execution State 与 Domain Artifact 分开

PydanticAI / LLM runtime 会产生很多运行时对象:

framework messages
retry counter
scratch reasoning
internal model state
tool-call transport messages

它们有助于执行和调试,但不应该自动等价于 BodySense 的领域模型。

真正可能进入 durable domain 的是:

DiagnosisAnalysis
DiagnosisCandidate
BodyStateHypothesis
Evidence
EvidenceGap
EvidenceAttempt
DecisionTrace
Configuration / Execution Provenance

[!important] 原则 Framework mechanics 会变,业务问题会留下。

未来 BodySense 仍然要回答“当时基于哪个 BodyState?提出了什么候选?缺了什么证据?为什么 ABSTAIN?”,即使底层已经不再使用今天的 PydanticAI message 类型。

Durable 的判断标准:不是“以后可能调试用”,而是“业务历史需要它”

可以用四个问题:

1. 未来业务是否需要独立引用?
2. 是否影响用户可见/业务决策?
3. 是否是历史解释/replay 必须的事实?
4. 如果不保存,未来能否可靠重建?

如果只是:

debug 时也许想看

更适合 observability storage,而不一定进入核心 domain schema。

Durable 不等于 Aggregate Root

一个对象值得持久化,不代表它一定要成为独立 Aggregate Root。

可以用四个问题判断:

  1. 未来业务是否需要独立引用它?
  2. 是否需要它解释原始决策?
  3. 它是否有跨 run 的独立生命周期?
  4. 它是否是 audit / replay 必需且无法安全重建的事实?

一个常见分类是:

对象Durable独立 ID跨 run 生命周期典型定位
DiagnosisAnalysis历史不可变Aggregate Root / immutable historical root
DiagnosisCandidate从属于一次 AnalysisAnalysis child
BodyStateHypothesis长期领域实体/子聚合
Evidence可复用/可引用领域证据实体
EvidenceGap是,若影响决策run-local identity通常否Trace/value artifact
EvidenceAttempt是,若实际发生run-local identityexecution/audit artifact
prompt message通常不作为业务实体execution trace
retry counter通常否ephemeral runtime state

因此:

Durable ≠ Aggregate Root

Aggregate Root 解决的是“不变量一致性”,不是“表要不要单独建”

DDD 中 Aggregate 的关键问题是:

哪些对象必须在一个事务边界内保持业务一致性?

而不是:

这个对象重要吗?

例如 EvidenceAttempt 很重要、值得永久保留,但通常不需要作为一个大型独立 Aggregate Root 协调业务状态。

这能避免一种常见过度建模:

每个 durable noun
→ 一个 repository
→ 一个 aggregate root
→ 一套 service

结果领域层被大量低价值抽象淹没。

DiagnosisAnalysis:历史分析快照,而不是“当前诊断对象”

DiagnosisAnalysis 应代表:

在某一个精确 BodyState revision、某一个 Agent Configuration 和某次实际 execution 下,系统当时产生并授权了什么分析结果。

核心不变量:

DiagnosisAnalysis is immutable
and pinned to one exact BodyState revision

例如:

R42

D100
Candidate C6 = medium

第二天用户新增信息:

R43

D101
Candidate C6 = low

正确做法是新增 D101,而不是把 D100 从 medium 改成 low。

[!tip] 记忆方式 过去的知识状态不能被今天的新知识倒写。

为什么 Analysis 应该是 Immutable Historical Artifact

因为它同时承担:

audit
replay
用户历史解释
configuration comparison
incident investigation

如果允许原地更新,就会破坏所有这些能力。

例如:

2026-08-20 D100 = ABSTAIN
2026-08-21 user provides new fact

若回写 D100:

D100 = ALLOW_NORMAL

你就再也回答不了:

“为什么 8 月 20 日系统要求补充信息?”

所以历史 Analysis 本质更像 event/history artifact,而不是 current mutable profile。

Current State 与 Historical Analysis 必须是两个不同概念

BodyState 是当前/纵向事实世界;DiagnosisAnalysis 是某次历史解释。

BodyState
→ evolves over time

DiagnosisAnalysis
→ immutable interpretation at a point in time

不要把:

current_diagnosis

简单做成 BodyState 上一个可不断覆盖的 JSON 字段,否则会混淆:

  • 当前认为怎样;
  • 历史当时怎样;
  • 何时改变;
  • 为什么改变。

为什么旧 EvidenceGap 也不能被后来信息“修复”

假设 D100 当时有:

G8 = “是否存在进行性肌力下降?”
critical = true
status = unresolved
Decision = ESCALATE

第二天 R43 才获得:

没有进行性肌力下降

不能回去修改:

D100.G8 = resolved

因为那会制造历史矛盾:

DecisionTrace:
G8 unresolved → ESCALATE

但数据库后来显示:
G8 resolved

正确模型是:

D100 / R42
G8 unresolved
ESCALATE
        ← 永久历史事实

R43
new analysis D101
        ← 当前新知识

这说明:

Past knowledge ≠ current knowledge
Historical truth ≠ current applicability

如果产品需要表达“D100 对当前状态已经过时”,应该增加单独的 freshness / applicability 状态,而不是篡改 D100 的历史内容。

Freshness / Applicability 应该是派生状态,不是修改历史内容

例如:

D100
source_body_state_revision = 42
immutable content

今天当前:

BodyState = R47

系统可以派生:

D100 applicability = stale

或:

superseded_by = D105

但 D100 自身当时的 candidate、gap、decision 不变。

这非常类似财务账本:后来发生新交易,不应该修改旧账单金额,而是追加新的事实。

DiagnosisCandidate 与 BodyStateHypothesis 不是同一个东西

这两个概念很容易混。

DiagnosisCandidate

表示:

某一次 immutable DiagnosisAnalysis 当时提出了什么可能性。

它是历史分析的 child:

D100
├─ C17 candidate
└─ C18 candidate

后续新的 run 不应该直接修改 C17 的 confidence 来表达“现在我们觉得不一样了”。

BodyStateHypothesis

表示:

一个可以随着 longitudinal BodyState 演化而加强、减弱、被反证或被关闭的长期解释性假设。

它可能跨多个 revision:

R42: hypothesis H3 = plausible
R43: H3 weakened
R47: H3 contradicted

所以:

Candidate
= what one analysis proposed

Hypothesis
= longitudinal explanatory entity

Candidate 可以在受控流程中 materialize/promote 为 Hypothesis,但两者不能因为“看起来都是疾病可能性”就共享同一生命周期。

为什么 Candidate Confidence 不能被后来直接改

如果 D100 的 C17:

confidence = HIGH

后来 D101 认为:

same concept confidence = LOW

不要改:

D100.C17 confidence HIGH → LOW

否则历史语义消失。

正确是:

D100/C17 = historical HIGH
D101/C23 = new LOW
H3 longitudinal status = weakened

Hypothesis 才是“跨时间整合”的位置。

Evidence:参与决策的外部证据要保存 provenance,但不能升级成 User Fact

如果 Evidence 真正参与了业务决策,至少应尽量保存:

evidence_id
source_type / source_key
source_version
retrieved_at
relevant snapshot / excerpt
metadata / provenance

原因包括:

  • 来源未来可能被删除;
  • 文档内容会更新;
  • index 会重建;
  • Historical Replay 需要恢复当时看见的版本。

但必须保持 epistemic separation:

External Evidence

User Fact

把一篇医学资料保存到用户记录里,并不会让“资料描述的典型症状”自动成为该用户自己的真实症状。

Evidence 的 Durable 价值来自“参与了决策”,不一定来自“永远有独立业务生命”

一个 Evidence 可能长期作为知识库对象存在,也可能只是保存一个当时 snapshot/reference。

真正要保护的是:

历史 Decision 使用的 Evidence
未来仍能被识别和解释

所以数据模型可以根据规模选择:

shared Evidence entity + revision

或:

Decision-local evidence snapshot/reference

但不能只存一段没有 identity 的最终文本。

EvidenceGap 与 EvidenceAttempt 为什么值得 durable

它们通常不是长期业务实体,但如果影响了最终 authority,就属于重要的历史决策证据。

EvidenceGap 保存“当时不知道什么”

例如:

G8
kind = user_information
critical = true
status = unresolved

它解释了为什么系统不能普通 AUTO。

EvidenceAttempt 保存“系统当时真的尝试了什么”

例如:

A7
├─ gap_id = G4
├─ strategy = search_knowledge
├─ returned = E21@v3
├─ outcome = insufficient
└─ budget_remaining = 0

Attempt 是 execution fact,不应由模型自由宣称“我已经搜过”。

为什么 Gap/Attempt 不一定需要全局 ID

它们多数只在一个 Analysis/Run 内有意义。

可以使用:

analysis-local stable identity

例如:

D100/G8
D100/A7

而不一定建立一个跨整个系统永远唯一、可独立查询的大实体。

关键是 identity 在其生命周期范围内稳定,并能支持 trace/replay。

三个 Identity 轴 + 一份 Execution Provenance

一个成熟 Diagnosis 历史至少有三个稳定 identity:

BodyState Revision
→ 当时用户事实世界是什么?

Agent Configuration ID
→ 当时批准的是哪套 Agent 行为?

DiagnosisAnalysis ID
→ 这个交点产生了哪一个 immutable 业务历史?

再加:

Execution Provenance
→ 这一轮实际上用了哪些模型 revision、provider、evidence、tool observations?

因此更精确的心智模型是:

DiagnosisAnalysis
=
Pinned Domain Input
×
Immutable AgentConfiguration
×
Observed Execution

而不是简单把 Diagnosis 看成 BodyState 的缓存字段。

Identity 与 Content 要分开

例如:

BodyState R53

R53 是 identity,不等于把所有 BodyState 字段复制进每个 Analysis。

可以同时保存:

reference to R53
+ minimal frozen snapshot/hash if replay requires

这样既保持关联,又避免无控制复制。

同理 Configuration ID 指向 immutable manifest,而不一定每个 Analysis 把全部 YAML 重复存一遍;如果审计要求,也可以存 canonical configuration snapshot/hash。

为什么同一个 BodyState 可以有多个 Analysis

例如:

D211 = R53 × C12 × Exec-A
D212 = R53 × C15 × Exec-B

BodyState 没变,但 Agent Configuration 升级了,所以两次分析都是合法历史。

甚至:

D220 = R53 × C15 × Exec-C
D221 = R53 × C15 × Exec-D

R 和 C 都一样,实际 evidence observations / provider fallback 等不同,也可能产生不同 Analysis。

不同不自动代表其中一个“错了”;应进一步检查 Behavioral Contract 是否满足,以及哪套 configuration 在 Eval 中表现更好。

是否应该 Deduplicate same R + same C Analysis?

不能机械 dedupe。

如果业务操作明确要求幂等:

same request id / same run id
→ must not create duplicate Analysis

这是 request idempotency。

但两个真正独立的分析请求,即使:

same R + same C

也可能因为 execution observations 不同形成合法的两个 historical Analysis。

所以:

Idempotency identity

Domain input identity

不要用 (body_state_revision, config_id) 做唯一键就认为万事大吉。

Longitudinal 系统为什么特别需要这种模型

一次性聊天机器人只关心:

“现在回答什么?”

BodySense 这类 longitudinal system 还必须回答:

当时知道什么?
为什么当时这样判断?
后来新增了什么?
为什么今天判断变化了?

Immutable historical artifact + mutable current state 的组合,让这四个问题可以同时成立。

一个完整时间线例子

T1
BodyState R42
→ Diagnosis D100
→ Candidate C17 HIGH
→ G8 unresolved
→ ABSTAIN

T2
User provides new information
→ BodyState R43

T3
New Diagnosis D101
→ Candidate C23 MEDIUM
→ G8-like gap resolved
→ ALLOW_NORMAL

T4
Hypothesis H3
→ longitudinal status updated from plausible to strengthened/weakened

历史中仍能看到:

D100 为什么 ABSTAIN
D101 为什么变化
H3 如何跨时间演化

如果只保留一个 current_diagnosis 字段,这些信息几乎全部丢失。

Treatment 为什么采用不同的 Durable Shape

Treatment 同样需要历史,但它还有“当前正在执行哪一个方案”的长期身份。

所以更适合:

Treatment Aggregate (mutable lifecycle/current pointer)
├─ Revision 1 immutable
├─ Revision 2 immutable
└─ Revision 3 immutable

而 Diagnosis 更自然是:

Analysis D100 immutable
Analysis D101 immutable
Analysis D102 immutable

这说明 durable domain shape 应由业务生命周期决定,而不是所有 Agent 输出都统一建模。

Consultation 为什么又不同

Consultation 还有:

conversation
run
runtime event log
checkpoint/thread
interrupt/resume

它需要区分:

Durable business run/event
vs
framework checkpoint state

LangGraph checkpoint 可以是 durable infrastructure state,但不应该自动替代 Go-owned business Run identity/event history。

Domain Artifact 与 Read Model 也要分开

UI 为了方便可能需要:

current diagnosis summary
latest candidate
active treatment

这些可以是 read model/projected view。

不能因为 UI 只展示“最新一个”,就把 domain history 也设计成只保留最新一个。

典型:

Immutable historical writes
→ projection
→ current/latest read model

而不是:

latest read model
= only source of truth

Migration / Refactor 时应该保护什么

即使底层 ORM/table 重构,至少保护:

Analysis identity
exact BodyState revision relationship
configuration identity
candidate historical content
DecisionTrace
EvidenceGap/Attempt historical state
execution provenance

只要这些语义稳定,底层表结构可以演化。

测试 Durable Domain 应覆盖什么

Immutability

D100 created
future BodyState changes
→ D100 content unchanged

Exact Pinning

Analysis references exact BodyState R42
not "current"

Multiple Analyses

same BodyState + different config
→ allowed as distinct history

Gap History

old unresolved Gap
future user fact arrives
→ old Gap remains unresolved in old Analysis

Candidate/Hypothesis separation

new analysis changes confidence
→ old Candidate unchanged
→ longitudinal Hypothesis may evolve

Provenance

configuration/execution provenance survives persistence round trip

Idempotency

same business request/run identity
→ no duplicate Analysis

而不是错误地:

same R+C always unique

自测题

  1. 为什么 PydanticAI message 不应该自动成为 Domain Object?
  2. Durable 与 Aggregate Root 有什么区别?
  3. DiagnosisAnalysis 为什么应该 immutable?
  4. 当前用户事实更新后,为什么不能回写旧 Analysis 的 EvidenceGap?
  5. Candidate 与 BodyStateHypothesis 为什么需要不同生命周期?
  6. Evidence durable 的核心价值是什么?
  7. same R + same C 为什么仍可能有多个合法 Analysis?
  8. Idempotency identity 和 domain input identity 有什么区别?
  9. Treatment 为什么更适合 Aggregate + immutable revisions?
  10. Read model “只显示最新”为什么不能反过来决定 domain 只保存最新?

常见误解

  • 用户状态变了,就把旧 Diagnosis 更新成最新结果:这会摧毁历史审计语义。
  • 所有 durable 对象都应该是 Aggregate Root:会导致领域模型被无意义拆散。
  • Candidate 就是长期 Hypothesis:两者的时间尺度与生命周期不同。
  • 存下 Evidence 就能把它当 Fact:Evidence 的 epistemic role 不能因为 persistence 改变。
  • 把完整 Agent message history 存下来就等于 domain model:执行框架日志不能替代稳定业务语义。
  • 同 BodyState + 同 Configuration 就必须只有一个 Analysis:只有同一个幂等业务请求才应防重复;真实独立执行可能合法产生不同历史。
  • UI 只看当前,所以历史没必要保存:纵向系统的价值恰恰来自可解释的历史变化。
创建于 2026/8/22 更新于 2026/8/23