1cd4344b16
事件集只有「一步走完」一种,带整条步记录而不是摘要;审计纪律由意图日志承担, 事件流保持可丢。写文档时去核 0007 决策二那笔「执行器原文丢了」的代价,发现它 并没有真的发生——「一步走完」这条记录持久化的是动作执行接缝的原样返回值,库只 替换了步记录里那一列,所以那段文本一直在日志里。 状态待确认:Event 从零字段变成有字段,按 CLAUDE.md §2 要过人类门。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
279 lines
20 KiB
Markdown
279 lines
20 KiB
Markdown
# Design 0013 · 事件集与具名回调清单
|
||
|
||
**日期** 2026-08-10 · **状态** 待确认
|
||
|
||
**落实** `0003-public-api-shape.md` 决策五那句「观察走事件流、干预走具名回调」,并补上
|
||
`0007-seam-behaviour.md`「留给后续的」那半个契约——事件出口的「投递失败不打断循环」已经定了,
|
||
「发出去的事件里有什么」还没有,因为 `Event` 到今天只有一个名字、零个字段。
|
||
|
||
**取代** `0007` 决策二末尾那句设想(「要补的话,将来靠事件流把执行器原文送出去做审计」)。
|
||
决策六换了另一条路,理由在那一节。
|
||
|
||
**触及** `../../src/polyloop/ports/`(`Event` 加字段、新增 `EventKind`)、
|
||
`../../src/polyloop/session/`(发事件的那一处)、`../../tests/contract/test_event_sink.py`。
|
||
|
||
**它要过 `../../CLAUDE.md` §2 那道人类门**,因为改的是公共类型的字段:`Event` 从零字段变成有
|
||
字段,而 `EventKind` 的取值集合从此是一份对外承诺。
|
||
|
||
## 读本文需要的几个名字
|
||
|
||
**接缝**——库留给项目替换的扩展点,用 Protocol 表达。本库有五个:模型调用、决策解释、动作
|
||
执行、存储、事件出口。**事件出口**这一个的形状是一个异步的 `emit(event: Event) -> None`,
|
||
加一个同步的 `parameters()`(后者交出这个实现的参数,进参数快照)。
|
||
|
||
**意图日志**——库把一次运行写成一串只追加的记录:运行开始、意图(「要做一件有副作用的事
|
||
了」)、模型调用结果、一步走完、运行结束。崩溃之后的恢复判定读这串记录,靠「有意图没结果」
|
||
这种形态判断上次停在哪一档。
|
||
|
||
**步记录(`StepRecord`)与「一步走完」记录(`StepCompleted`)是两个东西**。前者是逐步轨迹
|
||
里的一行,下游拿它做分析;后者是日志里的一条,它把动作执行接缝的**原样返回值**和那一行步
|
||
记录**一次原子写下去**。本文两处都要用到,不能互换。
|
||
|
||
**回填进历史**——历史是喂给模型的那串消息。把观察回填进历史,就是在下一次模型调用之前往
|
||
消息序列里追加一条,好让模型看见上一步的结果。
|
||
|
||
**参数快照**——装配时向每个接缝要一份字符串到字符串的映射,合并起来写进运行开始记录。续跑
|
||
时把当前装配现算的那份与记录里的逐字段比对,任何一项不一致直接报错。「模型看得见的东西必须
|
||
能进参数快照」是本库反复用到的一条判据:进不了快照,就意味着它可以在两次运行之间悄悄变化。
|
||
|
||
**`reference/pi`**——`reference/` 下六个参考仓库之一,另一个 agent 执行内核。本文引用它是当
|
||
反例,它不是本项目的设计依据。
|
||
|
||
## 需求从哪来
|
||
|
||
`../migrations/govdoc-saas.md` 的缺口登记里有一条:GovDoc 的审计出口是一个「发一条带类型和
|
||
载荷的事件」的接口,而它的业务侧有一条硬纪律——**agent 的原始输出、修复后的输出、恢复来源
|
||
全程留痕,禁止静默修复**。那条纪律迁过来之后要有地方承载,不然它迁不了。
|
||
|
||
它今天的事件模型写在 `reference/GovDoc-SaaS/research-wiki/schemas/docagent-audit-events.md`,
|
||
三类事件:
|
||
|
||
| 它的事件 | 埋点 | 载荷 |
|
||
|---|---|---|
|
||
| `llm_step` | 解析模型响应之后,成功与失败都记一条 | `session_id` `iteration` `call_id` `raw_content`(模型原文,全量不截断)`repaired_content`(修复之后的文本)`parse_error` `parse_ok` |
|
||
| `tool_call` | 工具执行之后,含无效调用 | `session_id` `iteration` `tool_name` `args` `output_digest`(工具输出的截断摘要)`is_valid` `duration_ms` |
|
||
| `phase_recovery` | 阶段执行器抢救产物成功时 | `session_id` `phase_name` `recovered_outputs` `recovered_from` |
|
||
|
||
第三类在本库界外——它发自阶段编排层,而阶段编排按 `../explanation/scope.md` 不归本库
|
||
(`0003` 决策五复述过这条)。前两类都在界内,而且**都是一次迭代发一条**。
|
||
|
||
dissect 那边没有对应需求:`reference/dissect/harness/agent/loop.py` 与 `harness/runner.py` 里
|
||
没有任何回调、钩子或进度上报。所以事件流现在只有一个真实消费者。
|
||
|
||
## 决策一:事件流是活的观察通道,它带的每一条事实在存储里另有一份
|
||
|
||
**不变量:一条事件里的任何事实,在存储里都能再找到一份。** 事件丢了,丢的是「早一点看见」,
|
||
不是那件事本身。
|
||
|
||
这条不是风格偏好,它是「投递失败不打断循环」那条契约成立的前提。出口允许抛异常、库接住并
|
||
继续跑(`0007` 已定),而这件事只有在事件不是任何事实的唯一出口时才安全——否则一个连不上的
|
||
后端会让一次运行的部分事实**静默消失**,而运行本身照常返回成功。
|
||
|
||
`0003` 驳回过反方向的那条路:给事件流加投递保证,让它承担审计。驳回的理由是一旦有投递保证,
|
||
事件流就变成持久结构,此后每加一个事件类型都要走人类门改 schema 版本。审计因此落到存储上,
|
||
而事件流的「可丢」靠上面那条不变量兑现。
|
||
|
||
## 决策二:那条审计纪律由存储承担,事件流只是让人不必轮询存储
|
||
|
||
纪律要留痕的三样东西,在意图日志里各有确定的位置:
|
||
|
||
| 纪律要的 | 在哪 |
|
||
|---|---|
|
||
| agent 的原始输出 | `ModelCallResult.reply.content`——模型调用结果记录里那段回复,库不改它 |
|
||
| 修复后的输出 | `StepRecord.raw_output`——解释器交回来的、回填进历史的那段 assistant 文本 |
|
||
| 恢复来源 | 意图日志本身:哪一步有意图没结果、按哪条重放策略处置过 |
|
||
|
||
**原文与修复后的文本落在两条不同的记录里,这不是巧合,是 `0002` 的结构决定的。** 模型调用
|
||
结算时写一条结果记录,那时还没解释;解释完、动作走完之后写一条「一步走完」,那里面的文本是
|
||
解释器交回来的。两次写之间隔着一次动作执行,所以两份文本天然分开存,而「解释器改写过什么」
|
||
正好是两者的差。
|
||
|
||
于是 `../../tests/contract/test_event_sink.py` 里那条
|
||
`test_audit_events_carry_both_raw_and_repaired_model_output` 的前提是错的:**事件不带这两样,
|
||
存储带**。它要改写成断言存储这一侧,落在驱动入口那一层的测试里——也就是跑完一次完整运行、
|
||
再把日志读回来,验里面能不能同时读出原文与修复后的文本。放在事件出口的契约套件里验不了,
|
||
因为那套件只看一个出口实现,看不到一次运行。
|
||
|
||
事件流剩下的用处只有一个:让同一个进程里的观察者不必轮询存储就能知道跑到哪儿了。GovDoc 的
|
||
审计管道要「不丢」,那它该包在存储接缝上,不是接在事件出口上——存储的写是运行的一部分,写
|
||
失败运行就停;事件的投递不是。
|
||
|
||
## 决策三:事件集只有一种取值——一步走完
|
||
|
||
```python
|
||
class EventKind(StrEnum):
|
||
STEP_FINISHED = "step_finished"
|
||
```
|
||
|
||
GovDoc 的两类界内事件都是**一次迭代发一条**,而一次迭代在本库就是一步。拆成两条只多出一个
|
||
信息:模型答完了但动作还没跑完。GovDoc 的工具是工作区里的文件操作,那段间隔可以忽略;dissect
|
||
的动作是执行一段 Python、可能很慢,但 dissect 根本不看事件。**一个真实消费者都举不出来的
|
||
分辨率,按 `0001` 决策三不实现也不预留。**
|
||
|
||
一步走完时那条步记录覆盖了它两类事件载荷的大部分:`raw_output` 是修复后的文本,`parse_ok`
|
||
与 `parse_error` 是解析结果,`tool_name` / `tool_arguments` / `observation` / `action_status`
|
||
是工具那一侧。**没有覆盖的有两样,各自的去处写在这里,免得下一个人以为它们被漏了:**
|
||
|
||
| 它的载荷 | 步记录里没有 | 去哪拿 |
|
||
|---|---|---|
|
||
| `raw_content`(模型原文) | 步记录里那段是修复后的 | 模型调用结果记录,见决策二 |
|
||
| `duration_ms`(工具单独耗时) | 只有整步墙钟 `step_wall_ms` | 现在拿不到,登记在下面的代价里 |
|
||
|
||
**没有「运行开始」与「运行结束」两种事件。** 事件出口是在进程内被 `await` 调用的,收到事件的
|
||
那段代码和调用 `run` 的是同一个进程:它自己知道运行是什么时候开始的,也会在运行结束时拿到
|
||
`RunResult`。**用返回值交付终态比用一条可丢的事件交付更可靠**——最后一条事件投递失败时终态
|
||
就丢了,而返回值丢不了。取消那一档拿不到返回值(`CancelledError` 原样抛给调用方),但调用方
|
||
正是取消的那一方,它知道;终态在存储的结束记录里。
|
||
|
||
要把进度写进一张业务表供另一个进程轮询的实现,照这个分工:逐步的行由事件出口写,终态那一行
|
||
由拿到 `RunResult` 的调用方写。
|
||
|
||
**`EventKind` 的取值集合从此是对外承诺**,加一个取值和改停止原因的取值一样要走人类门
|
||
(`../../CLAUDE.md` §2)。加取值本身在类型上是兼容的,但对一个「假定每条事件都是步事件」而
|
||
写的出口来说,新取值会被静默地当成步事件读——所以第一版就带 `kind`,让每个出口从第一天起
|
||
就得分发。这跟 `0003` 决策六是同一个判据:容器的形状要一次选对,里面装什么可以以后加。
|
||
|
||
## 决策四:事件带的是那条步记录本身,不是它的摘要
|
||
|
||
```python
|
||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||
class Event:
|
||
kind: EventKind
|
||
run_id: str
|
||
model_binding: Mapping[str, str]
|
||
step: StepRecord
|
||
```
|
||
|
||
数据类的这三个开关是本库所有公共数据类的统一形状,理由在 `0009`,不在本文重复。
|
||
|
||
**不挑几个字段拼一份摘要。** 挑出来的那份是一次投影,而投影会漂移:`0003` 决策五驳回观察
|
||
投影接缝时举的正是这个——参考仓库 `reference/pi` 的三份投影已经漂移到同一条记录在正常回合
|
||
可见、在压缩历史里不可见。带整条记录的话,`StepRecord` 加一个字段,事件里自动就有,两边不
|
||
可能对不上。
|
||
|
||
**`run_id` 在场,因为一个出口可以被并发的多次运行共用。** 事件出口挂在定义上(`0003` 决策
|
||
三),而定义可以被多次运行共用;没有运行标识,两次并发运行的事件在出口那边混成一串。
|
||
|
||
**`model_binding` 在场,因为项目自己那套标识没法从运行标识倒推。** GovDoc 的审计要求三类事件
|
||
必带会话标识,而会话标识是它自己的标识、住在请求的绑定里;一次会话包含三个阶段、也就是三次
|
||
运行,所以运行标识与会话标识不是一回事。让项目把会话标识编进运行标识再解析出来,等于往一个
|
||
库明确声明「不解析」的不透明字符串里塞结构。字段名与请求上那个 `model_binding` 逐字相同,
|
||
因为它就是同一份映射原样传过来的;值只能是字符串这条约束也一并继承,理由在 `0003` 决策三
|
||
——它的全部键值都要进参数快照,而快照是一份字符串到字符串的映射。
|
||
|
||
**没有 `schema_version`。** 事件不是持久化结构,`../../CLAUDE.md` §1.4 管的是会被下游存进
|
||
数据库或实验数据集的结构。下游把事件存下来时,存的是它自己那张表的 schema。而真正需要版本
|
||
的那部分——步记录——本来就带着自己的 `schema_version` 一起进来了。
|
||
|
||
## 决策五:只有一处发事件的地方,在「一步走完」原子落地之后
|
||
|
||
一步走完、`StepCompleted` 原子落地、**然后**发事件,再进入下一次迭代的停止判定。
|
||
|
||
**先写后发,顺序不能倒过来。** 倒过来的话,进程崩在发事件与落盘之间,观察者看见了一步而
|
||
存储里没有——那正是决策一那条不变量被打破的样子,而它的表现是「进度表里有第 7 步、日志里
|
||
只到第 6 步」,谁也说不清哪个是真的。
|
||
|
||
**发在停止判定之前,因为停止判定可能不返回。** 判定一旦决定收尾,控制流就去写结束记录、
|
||
组装 `RunResult`、返回给调用方了。把发事件排在判定之后,最后一步就没有事件——而一次运行的
|
||
最后一步恰恰是最值得看见的那一步(它是「做完了」还是「预算烧光了」)。排在判定之前,每一步
|
||
都发,没有例外要记。
|
||
|
||
**续跑时不为已经完成的步补发事件。** 事件是「这次进程里真的发生了什么」的实时投影,补发等于
|
||
宣称一件早就发生过的事刚刚发生,而接进度表的那一侧会多出一批重复行。判据是**这次进程里有没有
|
||
真的执行**:从日志里读回来直接跳过的步不发;按重放策略在这次进程里重新执行了一遍的步照发。
|
||
观察者要补全前半段,从存储里读。
|
||
|
||
**投递不排队、不并发、不缓冲。** 一条一条 `await` 过去,按发生顺序。排队要引入后台任务、
|
||
背压和关闭时的排空,而那是治理,按 `../../CLAUDE.md` §1.5 不在本库做。代价照实认下:一个慢
|
||
出口会按步拖慢整次运行。要异步就在出口实现里自己排队——它知道自己能丢多少,库不知道。
|
||
|
||
## 决策六:执行器被丢掉的那段原文已经在日志里,不必再开一条路
|
||
|
||
`0007` 决策二定了动作被拒绝或环境故障时,回填进历史的是库合成的那段文本,执行器返回的
|
||
`observation` 不进历史。它同时认下一笔代价——「哪个参数不合法」这种只有执行器知道的信息
|
||
丢了——并设想将来靠事件流把它送出去做审计。
|
||
|
||
**那笔代价没有实际发生,因为它只是没进历史,并没有没进日志。** 「一步走完」这条记录同时写
|
||
两样东西:动作执行接缝的**原样返回值**,以及那一行步记录。库替换的只是步记录里的
|
||
`observation` 那一列,接缝返回的整个结构原样落盘,其中就包括执行器自己写的那段文本。
|
||
|
||
于是那条设想不必落地,而且**不该落地**:走事件流的话,出口一失败,那段文本就再也查不出来了,
|
||
而 GovDoc 那条纪律的原话正是「禁止静默修复」——一次被库替换掉的观察,替换前的样子只能存在
|
||
于一个可丢的通道里,这本身就是一次可能静默的修复。它今天审计里那个 `output_digest` 在无效
|
||
调用那一档记的,正是它的工具调度器给出的那段拒绝说明;对应到本库,那就是动作执行接缝返回的
|
||
`observation`,而它在日志里。
|
||
|
||
**代价是拿它要读日志,读不到轨迹里。** 下游拿到的 `RunResult.steps` 是步记录序列,里面没有
|
||
这一段;要审计就得把日志读回来。接受,因为另一头是往每一条步记录上再挂一份可能上兆的文本,
|
||
而需要它的只有出错的那几步。
|
||
|
||
## 决策七:投递失败按次计数,只算这次进程的
|
||
|
||
`RunResult.event_delivery_failures` 已经在类型里了,它的口径定在这里。
|
||
|
||
**捕获 `Exception`,不捕获 `BaseException`。** `asyncio.CancelledError` 必须原样穿过
|
||
(`../../CLAUDE.md` §1.6)——在发事件这一下把取消吞掉,取消就会晚一整步才生效。
|
||
|
||
**每一次抛异常的 `emit` 计一次。** 不去重、不按步合并:出口连续失败十步就是十,那个数字要
|
||
能反映「有多失败」,而不只是「失败过」。
|
||
|
||
**捕获之后记一条错误日志,不静默继续**(`../../CLAUDE.md` §1.7),也**不把失败转成一条事件
|
||
从同一个出口再发一次**——`0003` 决策四已经定了这条,理由是自我喂食:一个持续失败的出口会让
|
||
失败处理路径变成递归,而递归的表现是进程卡住或栈溢出,不是一条错误日志。
|
||
|
||
**计数是每次运行的,不是每个出口的。** 出口挂在定义上、可以被并发的多次运行共用,而这个数
|
||
属于一次运行的结果,所以它住在运行自己的状态里。
|
||
|
||
**不跨进程累加。** 崩溃续跑之后,返回值里的数字是这次进程投递失败的次数,上一次进程那些不
|
||
算在内。要累加就得把它写进结束记录,而那会把一个纯观察量变成持久结构——`0003` 驳回给事件流
|
||
加投递保证时说的正是这个。
|
||
|
||
## 决策八:具名回调清单现在是空的
|
||
|
||
`0003` 决策五定了「观察走事件流、干预走具名回调」。事件流那一半上面定完了,另一半没有内容:
|
||
**本库现在一个具名回调都不提供。**
|
||
|
||
这不是遗漏。GovDoc 那套钩子有四个扩展点,逐个都已经有别的归宿:
|
||
|
||
| 它的钩子 | 在本库的归宿 |
|
||
|---|---|
|
||
| 步骤开始前注入上下文 | 上下文装配不设接缝(`0003` 决策五),注入走请求上的注入槽(`0010`) |
|
||
| 工具执行后替换工具输出 | 观察投影不设接缝(`0003` 决策五),那条驳回举的反例正是这个钩子——它的文档承诺「替换」而代码做的是「追加」 |
|
||
| 步骤结束后持久化中间状态 | 存储接缝在做这件事,而且它是必填的(`0003` 的否决方案里驳回了「做成可选参数」) |
|
||
| 循环结束后做遥测与清理 | `run` 的返回值就是这个 |
|
||
|
||
**将来要加一个具名回调,判据两条**:按 `0001` 决策三得有两个真实消费者;而且它改动的东西
|
||
必须能进参数快照——回调一旦能改变模型看见的内容,那份内容就成了这次运行的参数,不进快照
|
||
就等于有一个影响行为又读不出来的默认值。
|
||
|
||
**观察通道与干预通道的分界在失败处置上:观察通道失败了运行继续,干预通道失败了运行必须停。**
|
||
一个没生效的干预和一个生效了的干预会产出两次不同的运行,而静默继续意味着事后分不出是哪一种。
|
||
事件出口按前者处置(接住、计数、继续),任何将来的回调按后者(原样抛出、终止运行)。
|
||
|
||
## 代价
|
||
|
||
**慢出口按步拖慢运行。** 决策五不排队,所以一个每步花两百毫秒写数据库的出口,在五十步的
|
||
长运行上就是十秒。缓解只能在出口实现里做。
|
||
|
||
**一条事件带着整条步记录,可能很大。** `raw_output` 与 `observation` 都没有长度上限,出口
|
||
每步都会收到一份完整副本。
|
||
|
||
**工具单独的耗时拿不到。** 库只记整步墙钟,里面混着模型调用、解释和动作执行三段。GovDoc 今天
|
||
的 `tool_call` 事件记的是工具那一段,迁过来之后这个数会变成整步的数,两批数据不可比。这一条
|
||
登记进 `../migrations/govdoc-saas.md` 的缺口清单,不在本文解决——要拆开就得往步记录里加分段
|
||
计时,而那是一次持久化结构的改动,得有人先说清楚这个数拿去做什么。
|
||
|
||
**只有一种事件,看不见「模型答完了、动作还在跑」。** 一个动作耗时很长的项目,进度表在那段时间
|
||
里不会更新。等到有第二个消费者真的需要,加一个取值,那时 `Event.step` 要变成可为空并配一条
|
||
不变量——这是决策三承认的、加取值那天要付的结构成本。
|
||
|
||
## 留给后续的
|
||
|
||
**流式的中间增量还是不透出。** `0012` 文末把这件事指到本文,答案是不加:一次调用之内的增量
|
||
不是一步的结果,让它进事件流就要给事件一个「一步之内多条」的形状,而那要求事件带序号和排序
|
||
保证——那正是 `0003` 驳回的投递保证的开头。等有消费者再说。
|
||
|
||
**阶段级的抢救事件在界外。** GovDoc 的 `phase_recovery` 发自阶段执行器,那一层不归本库;它
|
||
要发什么事件由它自己定。本库这边它需要的是「这次运行结束了没有、结果是什么」,那是存储的
|
||
结束记录在回答。
|