BodySense Diagnosis 的 Durable Domain Model
把 DiagnosisAnalysis、Candidate、Hypothesis、Evidence、EvidenceGap 与 EvidenceAttempt 按 durable identity、生命周期和 epistemic role 分层,解释为什么历史诊断应 immutable 而不是被后续健康状态改写。
[!info] related notes
- 所属 MOC: bodysense-moc、agent-moc
- 相关概念: agent-decision-trace、agent-configuration-and-execution-provenance、decision-relevant-evidence-gap
- 易混淆概念: 当前状态 vs 历史分析、Durable vs Aggregate Root
- 相关资源: bodysense-diagnosis-agent-architecture
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。
可以用四个问题判断:
- 未来业务是否需要独立引用它?
- 是否需要它解释原始决策?
- 它是否有跨 run 的独立生命周期?
- 它是否是 audit / replay 必需且无法安全重建的事实?
一个常见分类是:
| 对象 | Durable | 独立 ID | 跨 run 生命周期 | 典型定位 |
|---|---|---|---|---|
| DiagnosisAnalysis | 是 | 是 | 历史不可变 | Aggregate Root / immutable historical root |
| DiagnosisCandidate | 是 | 是 | 从属于一次 Analysis | Analysis child |
| BodyStateHypothesis | 是 | 是 | 是 | 长期领域实体/子聚合 |
| Evidence | 是 | 是 | 可复用/可引用 | 领域证据实体 |
| EvidenceGap | 是,若影响决策 | run-local identity | 通常否 | Trace/value artifact |
| EvidenceAttempt | 是,若实际发生 | run-local identity | 否 | execution/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
自测题
- 为什么 PydanticAI message 不应该自动成为 Domain Object?
- Durable 与 Aggregate Root 有什么区别?
- DiagnosisAnalysis 为什么应该 immutable?
- 当前用户事实更新后,为什么不能回写旧 Analysis 的 EvidenceGap?
- Candidate 与 BodyStateHypothesis 为什么需要不同生命周期?
- Evidence durable 的核心价值是什么?
same R + same C为什么仍可能有多个合法 Analysis?- Idempotency identity 和 domain input identity 有什么区别?
- Treatment 为什么更适合 Aggregate + immutable revisions?
- Read model “只显示最新”为什么不能反过来决定 domain 只保存最新?
常见误解
- 用户状态变了,就把旧 Diagnosis 更新成最新结果:这会摧毁历史审计语义。
- 所有 durable 对象都应该是 Aggregate Root:会导致领域模型被无意义拆散。
- Candidate 就是长期 Hypothesis:两者的时间尺度与生命周期不同。
- 存下 Evidence 就能把它当 Fact:Evidence 的 epistemic role 不能因为 persistence 改变。
- 把完整 Agent message history 存下来就等于 domain model:执行框架日志不能替代稳定业务语义。
- 同 BodyState + 同 Configuration 就必须只有一个 Analysis:只有同一个幂等业务请求才应防重复;真实独立执行可能合法产生不同历史。
- UI 只看当前,所以历史没必要保存:纵向系统的价值恰恰来自可解释的历史变化。