docs(design): 按两轮硕士生冷读改 0011 与 0012
0011 最要紧的一条是文档和代码对不上:Codex 那轮把坏行判据从「第一条解不开的行」改成了「有没有 被换行终结」,文档还停在旧规则上。改完顺带答掉冷读问的「末尾连着两条坏行算什么」——按新规则 第一条终结过的坏行就已经报错了。 三处确定性矛盾全部成立:标题写「fsync 在三处,其余三处不做」而正文写「那两次」、表格里 fsync=否 只有两行(改成按「一步之内四次写」重排,并把「处」的单位说清);「五个记录类」里没有「动作 结果」(它是逐步结果那条记录的一个字段,不是第六个记录类,表格行名会误导);「每步四次写」与 表格看着像五次(同一根因)。另外契约套件的状态从「24 条全跳过」改成不给会过期的数字,并把 skip 与 xfail 分开说——它们是「还没有实现」与「没有机器兜底」两回事。 还补了:一节名词解释(前缀持久性、耐久屏障、恢复判定、⑥ 都是首次出现即使用);「取消能穿过去」 那段原本自相矛盾(说线程会把写做完,又说没写完的是尾行);文件的字面约定(UTF-8、\n 结尾、 非 ASCII 不转义、目录不存在时创建)——这些恰恰是外部读取方必须知道的,而文档反复强调那份日志 要能离开这个库读懂;为什么保留键叫 record 而不是加下划线前缀;为什么用 to_thread;独占创建 只挡住一种撞车(两个进程同时续跑挡不住,登记为已知缺口);以及运行开始那次 fsync 真正的理由 是目录项而不是「读不出配置」。 最实的一条留到最后:那五维里的题目很可能带中文或空格,过不了运行标识的字符判据,而同一份 文档又规定不做转义。现在写明编码方式归下游自己选(要单射),并说清库为什么不替它选——库一旦 选了,文件名就不再等于运行标识,而它按标识去目录里找文件的用法就断了。migrations/dissect.md 同步登记。 0012:把 parent_card_id 这个笔误改成 parent_call_id(冷读的人不知道哪个对,只能问「card 是 什么」,正好把它顶出来);「每个源报四样」实际枚举了五样;补一节名词解释(scope、源、恒定采样 参数、推理开关全是首次出现即使用);补上本库这一侧的接缝签名与 ModelCall 的五个字段,并说明 中间那三个为什么一个都不往下传;补上非文本块报错、拼接不加分隔符的代价、1.1.1 那个 bug 到底 是什么、空串调用标识是防御而不是常规路径、以及为什么超时与重试次数不算模型身份。
This commit is contained in:
@@ -6,8 +6,31 @@
|
|||||||
决策二、五。那两份定了存储接缝有哪六个方法、写入粒度是什么、哪两次写是耐久屏障、以及前缀
|
决策二、五。那两份定了存储接缝有哪六个方法、写入粒度是什么、哪两次写是耐久屏障、以及前缀
|
||||||
持久性这条要求;本文定**第一个真实实现**怎么满足它们。
|
持久性这条要求;本文定**第一个真实实现**怎么满足它们。
|
||||||
|
|
||||||
**触及** `../../src/polyloop/stores/`,以及 `../../tests/contract/` 那套套件——它现在 24 条
|
**触及** `../../src/polyloop/stores/`,以及 `../../tests/contract/` 那套套件——存储那部分在本文
|
||||||
全跳过,因为没有实现可接。
|
落地之前**全部跳过**,因为没有实现可接。(那套套件里另有两条标着 `xfail`,那是两条**已知没有
|
||||||
|
机器兜底**的承诺,和「还没有实现」是两回事,见文末。)
|
||||||
|
|
||||||
|
## 读本文需要的几个名字
|
||||||
|
|
||||||
|
**一步之内写四次**(`0003` 决策四):模型调用意图 → 模型调用结果 → 动作意图 → **逐步结果**。
|
||||||
|
最后那一条是**一条记录**,里面同时装着动作结果与步记录——`0005` 决策二要求它们一次原子落地,
|
||||||
|
所以它们本来就是同一个记录类的两个字段,不是两次写。加上一次运行开头的「运行开始」与结尾的
|
||||||
|
「运行结束」,一次两步的运行一共写十次。
|
||||||
|
|
||||||
|
**记录类一共五个**:运行开始、意图(模型调用与动作共用一个类,靠一个字段区分)、模型调用
|
||||||
|
结果、逐步结果、运行结束。
|
||||||
|
|
||||||
|
**耐久屏障**:一次必须确认已经落盘才能往下走的写。四次写里前三次的第一次和第三次是屏障
|
||||||
|
——意图必须在副作用之前就持久,否则「做过没有」这个问题事后没有答案。
|
||||||
|
|
||||||
|
**前缀持久性**(`0005` 决策五):第 k 次写被确认持久时,第 1 到 k-1 次也已经持久。有了它,
|
||||||
|
不是屏障的那几次写也不会掉在屏障后面。
|
||||||
|
|
||||||
|
**恢复判定**(`polyloop._recovery`,库内部的一个纯逻辑模块):拿一份读回来的日志,按「意图
|
||||||
|
有没有 / 结果有没有」判每一次执行处在哪一态,据此决定从哪儿接着跑。
|
||||||
|
|
||||||
|
**⑥ 迁移验收**是 `../../README.md` 那份阶段清单的最后一项:真的把两个下游迁过来,以两边测试
|
||||||
|
全绿为准。
|
||||||
|
|
||||||
## 这份实现为什么值得一份 design doc
|
## 这份实现为什么值得一份 design doc
|
||||||
|
|
||||||
@@ -38,8 +61,17 @@
|
|||||||
**迁移注意:运行标识要带齐能唯一定位这次运行的每一维。** 某个下游今天的轨迹文件名是
|
**迁移注意:运行标识要带齐能唯一定位这次运行的每一维。** 某个下游今天的轨迹文件名是
|
||||||
`r{轮次}__{阶段}__{题目}__s{种子}__a{尝试}.jsonl` 五维拼出来的,而且它的注释记了一个踩过的
|
`r{轮次}__{阶段}__{题目}__s{种子}__a{尝试}.jsonl` 五维拼出来的,而且它的注释记了一个踩过的
|
||||||
坑——阶段那一维原先漏了,导致同一次 run 先后两个阶段的轨迹静默互相覆盖。它迁过来时那五维要
|
坑——阶段那一维原先漏了,导致同一次 run 先后两个阶段的轨迹静默互相覆盖。它迁过来时那五维要
|
||||||
拼进运行标识,否则同样的覆盖会以「日志里有别人的记录」的形式重演。这条登记进
|
拼进运行标识,否则同样的覆盖会以「日志里有别人的记录」的形式重演。
|
||||||
`../migrations/dissect.md`。
|
|
||||||
|
**那五维里有一维(题目)不保证是安全字符**,而这条判据不做转义。所以拼运行标识这件事**归
|
||||||
|
下游**:它自己选一种**单射**的编码把不安全的部分变成安全字符(哈希、百分号编码、自己维护一张
|
||||||
|
映射表都行),选哪种由它定,因为只有它知道那份标识事后要怎么被人认出来。
|
||||||
|
|
||||||
|
**库这边不替它选,理由和不做转义是同一条**:库一旦选了一种编码,文件名就不再等于运行标识,
|
||||||
|
而那个下游按标识去目录里找文件的用法就断了;更糟的是两套标识(原始的、编码后的)从此要互相
|
||||||
|
翻译,而翻译表是又一处会漂移的地方。
|
||||||
|
|
||||||
|
这两条都登记进 `../migrations/dissect.md`。
|
||||||
|
|
||||||
## 决策二:一行一条记录,行首加一个类型标签
|
## 决策二:一行一条记录,行首加一个类型标签
|
||||||
|
|
||||||
@@ -48,52 +80,79 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
`polyloop.serialization` 编出来的载荷**只有记录类自己的字段,没有任何元信息键**——那是刻意
|
`polyloop.serialization` 编出来的载荷**只有记录类自己的字段,没有任何元信息键**——那是刻意
|
||||||
的,为的是一条步记录的载荷和迁移前那份逐行轨迹同形。哪一行是哪种记录由存储自己解决,所以
|
的:某个下游今天自己写一份逐行的轨迹文件(一行一步),迁移之后那份文件由它拿本库返回的步序列
|
||||||
标签是这一层加的。
|
重组,而它的验收标准是「轨迹与迁移前逐字段可比」。载荷里多一个键,那份文件就不同形了。哪一行
|
||||||
|
是哪种记录由存储自己解决,所以标签是这一层加的。
|
||||||
|
|
||||||
**键名 `record` 从此是保留键。** 五个记录类现在都没有叫这个名字的字段,将来也不许加——加了
|
**键名 `record` 从此是保留键。** 五个记录类现在都没有叫这个名字的字段,将来也不许加——加了
|
||||||
的话,编码出来的字典会和标签撞,而撞的表现是解码时把一条记录读成另一种。这条约束写在
|
的话,编码出来的字典会和标签撞,而撞的表现是解码时把一条记录读成另一种。这条约束写在
|
||||||
`stores` 的模块 docstring 与 `serialization` 的编码说明里。
|
`stores` 的模块 docstring 与 `serialization` 的编码说明里。
|
||||||
|
|
||||||
|
**不加下划线前缀之类的记号,虽然那样从形状上就撞不了。** 这份日志的一个明确用途是让人和模型
|
||||||
|
直接翻文件读(`0005` 决策一),而 `_record` 这种键在那种场景里读着像内部字段、像不该看的东西。
|
||||||
|
一个普通单词加一条「不许再叫这个名字」的约束,换来的是每一行第一眼就读得懂。
|
||||||
|
|
||||||
**取值就是记录类名的蛇形写法**(`run_started` / `intent` / `model_call_result` /
|
**取值就是记录类名的蛇形写法**(`run_started` / `intent` / `model_call_result` /
|
||||||
`step_completed` / `run_finished`),不另起一套短名。短名省的那几个字节抵不上「查一个名字要
|
`step_completed` / `run_finished`),不另起一套短名。短名省的那几个字节抵不上「查一个名字要
|
||||||
先查一张对照表」的成本。
|
先查一张对照表」的成本。
|
||||||
|
|
||||||
## 决策三:撕裂的尾行丢掉,中间的坏行是损坏
|
## 决策三:判据是「这一行有没有被换行终结」,不是「它能不能解析」
|
||||||
|
|
||||||
按行扫,**第一条解不开的行就是日志的结尾**;如果它后面还有解得开的行,那不是撕裂而是损坏,
|
文件末尾那段**没有换行的**字节丢掉;**每一条被换行终结的行都必须解得开**,解不开就是损坏,
|
||||||
直接报错。
|
直接报错。空行跳过——它不携带记录,也不是撕裂的证据。
|
||||||
|
|
||||||
**为什么尾行可以丢。** 进程被杀在一次 `write` 中途,文件末尾会留下半行。那半行对应的那次
|
**为什么尾行可以丢。** 一次写入是先写整行、再由调用方等它返回。进程被杀在 `write` 中途,
|
||||||
写入从来没有被确认过——调用方还没等到那个 `await` 返回,所以按契约它就是「没发生」。丢掉它
|
文件末尾留下的那段字节对应的那次写**从来没有被确认过**,按契约它就是「没发生」。丢掉它正是
|
||||||
正是「要么都可见、要么都不可见」的落地方式。
|
「要么都可见、要么都不可见」的落地方式。
|
||||||
|
|
||||||
**为什么中间的坏行不能丢。** 追加写只在末尾产生撕裂;中间出现读不了的字节意味着别的东西
|
**为什么判据不能是「能不能解析」。** 这一条是对抗审查逼出来的,失败场景很具体:`os.write`
|
||||||
(写坏、外部改动、两个进程交错写)动过这个文件。这时候跳过那一行接着读,会拼出一份少了几条
|
允许短写,而短写完全可能正好写完整个 JSON 对象、只差最后那个换行。那段字节解得开,于是一条
|
||||||
记录但看起来完整的日志,而恢复会照它做判断。`0002` 决策二那条「读到说不通的状态就失败,不
|
**从没被确认过的动作意图**被当成有效记录读回来;恢复据此判成「状态未知」,声明可重放的话就
|
||||||
修复也不带着它继续」在这里同样适用。
|
把那个动作再执行一次——**而它一定没执行过**,因为调用方是在写意图返回之后才去执行的。
|
||||||
|
|
||||||
|
换行是「这一行写完了」的唯一凭据,所以判据只能是它。
|
||||||
|
|
||||||
|
**为什么终结过的坏行不能丢。** 追加写只在末尾产生撕裂;一条完整终结的行读不了,说明别的东西
|
||||||
|
(写坏、外部改动、两个进程交错写)动过这个文件。这时候跳过它接着读会拼出一份少了几条记录但
|
||||||
|
看起来完整的日志,而恢复会照它做判断。`0002` 决策二那条「读到说不通的状态就失败,不修复也不
|
||||||
|
带着它继续」在这里同样适用。**按这条规则,「末尾连着两条坏行」也是损坏**——第一条终结过的
|
||||||
|
坏行就已经报错了。
|
||||||
|
|
||||||
**「碰到坏行就放弃这个文件」和某个下游今天的做法一致**:它的轨迹检查器把整个文件的读取包在
|
**「碰到坏行就放弃这个文件」和某个下游今天的做法一致**:它的轨迹检查器把整个文件的读取包在
|
||||||
一个 `except (json.JSONDecodeError, UnicodeDecodeError)` 里,撞到第一条坏行就把整个文件降级成
|
一个 `except (json.JSONDecodeError, UnicodeDecodeError)` 里,撞到第一条坏行就把整个文件降级成
|
||||||
一条违规,而不是跳过坏行接着读;它还有一条专门造「轨迹文件被截断成半行」的测试。这条不是我们
|
一条违规,而不是跳过坏行接着读;它还有一条专门造「轨迹文件被截断成半行」的测试。这条不是我们
|
||||||
新发明的谨慎。
|
新发明的谨慎。
|
||||||
|
|
||||||
**空行跳过,不算坏行。** 它不携带任何记录,也不是撕裂的证据。
|
## 文件的字面约定
|
||||||
|
|
||||||
## 决策四:`fsync` 在三处,其余三处不做
|
外部读取方要知道的就这几条:**UTF-8 编码,每行以 `\n` 结尾,非 ASCII 原样输出不转义**
|
||||||
|
(`ensure_ascii=False`,那份日志给人读、也给模型读,转义成 `\uXXXX` 谁都难受)。JSON 自己会把
|
||||||
|
换行、制表符这类控制字符转义掉,所以**一条记录里的换行不会把它拆成两行**。
|
||||||
|
|
||||||
| 写什么 | `fsync` | 为什么 |
|
目标目录不存在时**创建**,不报错——运行标识是调用方给的,目录是它配的,第一次跑时它还不存在
|
||||||
|---|---|---|
|
是正常情形。
|
||||||
| 运行开始 | 是 | 它是整份日志的头,丢了就读不出这次运行按哪份配置跑 |
|
|
||||||
| 两条意图 | 是 | `0003` 决策四定的耐久屏障:必须落盘才能发出调用 / 执行动作 |
|
## 决策四:一步之内四次写有两次 `fsync`,加上运行开始与运行结束
|
||||||
| 模型调用结果 | 否 | 后面紧跟的不是副作用 |
|
|
||||||
| 动作结果与步记录 | 否 | 同上 |
|
| 写什么 | 每步几次 | `fsync` | 为什么 |
|
||||||
| 运行结束 | 是 | 丢了的话这次运行看起来还能续,而它已经跑完了 |
|
|---|---|---|---|
|
||||||
|
| 运行开始 | —(整次一回) | 是 | 见下面那段,它的理由比别的都硬 |
|
||||||
|
| 模型调用意图 | 1 | 是 | 耐久屏障:必须落盘才能发出调用 |
|
||||||
|
| 模型调用结果 | 1 | 否 | 后面紧跟的不是副作用 |
|
||||||
|
| 动作意图 | 1 | 是 | 耐久屏障:必须落盘才能执行动作 |
|
||||||
|
| 逐步结果 | 1 | 否 | 同上。它是**一条**记录,动作结果与步记录是它的两个字段 |
|
||||||
|
| 运行结束 | —(整次一回) | 是 | 丢了的话这次运行看起来还能续,而它已经跑完了 |
|
||||||
|
|
||||||
**不做 `fsync` 的那两次靠前缀持久性兜。** 同一个文件的追加写,后一次 `fsync` 会把它之前的
|
**不做 `fsync` 的那两次靠前缀持久性兜。** 同一个文件的追加写,后一次 `fsync` 会把它之前的
|
||||||
全部内容一起刷下去,所以「模型调用结果还没落盘,动作意图(屏障)落了盘」这个状态在这份实现
|
全部内容一起刷下去,所以「模型调用结果还没落盘,动作意图(屏障)落了盘」这个状态在这份实现
|
||||||
上不可能出现——`0005` 决策五要求的正是这个,而这份实现是天然满足的那一类。
|
上不可能出现——`0005` 决策五要求的正是这个,而这份实现是天然满足的那一类。
|
||||||
|
|
||||||
|
**运行开始那次还要把父目录也刷一遍。** `fsync(fd)` 刷的是文件内容,刷不到「这个目录里多了
|
||||||
|
一个文件」这条目录项。掉电之后内容可能在、而**文件根本不存在**——那时读日志走「文件不存在」
|
||||||
|
返回空日志,驱动入口据此判成一次全新的运行,于是一次已经开始过、可能已经花过钱的运行静默
|
||||||
|
没了留痕。这比「读不出配置」严重一档,是这一次 `fsync` 真正的理由。只在新建文件时做:往已有
|
||||||
|
文件追加不改目录项。
|
||||||
|
|
||||||
**每次写都是「打开、追加、按需 `fsync`、关闭」,不长期持有文件句柄。** 持有句柄要为每个运行
|
**每次写都是「打开、追加、按需 `fsync`、关闭」,不长期持有文件句柄。** 持有句柄要为每个运行
|
||||||
标识维护一份状态,而那份状态在并发下就是共享可变状态;打开的成本相对于一次 `fsync` 可以忽略,
|
标识维护一份状态,而那份状态在并发下就是共享可变状态;打开的成本相对于一次 `fsync` 可以忽略,
|
||||||
而一次 `fsync` 相对于一次模型调用又可以忽略。
|
而一次 `fsync` 相对于一次模型调用又可以忽略。
|
||||||
@@ -118,9 +177,16 @@
|
|||||||
`aiofiles`),其中一个的循环上还挂着一个心跳协程——写文件一慢,心跳跟着晚。它们现在没被这件事
|
`aiofiles`),其中一个的循环上还挂着一个心跳协程——写文件一慢,心跳跟着晚。它们现在没被这件事
|
||||||
咬到,是因为写得少:一个是整次跑完写一个文件,另一个根本不落盘。本库是每步四次写,量级不同。
|
咬到,是因为写得少:一个是整次跑完写一个文件,另一个根本不落盘。本库是每步四次写,量级不同。
|
||||||
|
|
||||||
**取消能穿过去。** `to_thread` 那一下被取消时,协程立刻抛出取消,而那个线程会把手上这次写
|
**取消能穿过去,而且不会留下说不通的日志。** `to_thread` 那一下被取消时,协程立刻抛出取消,
|
||||||
做完——写完的东西留在文件里,没写完的是尾行,按决策三丢掉。两种结果都不会让日志进入说不通
|
**但那个线程不会被打断**——它会把手上这次写做完,那一行是完整的。所以取消这条路上根本不产生
|
||||||
的状态。
|
尾行;产生尾行的是另一件事(进程被杀),那时线程连同整个进程一起没了,写到一半的那段按决策三
|
||||||
|
丢掉。两种情形都不会让日志进入说不通的状态。
|
||||||
|
|
||||||
|
**为什么是 `to_thread` 而不是 `run_in_executor` 或者 `aiofiles`。** 前者是同一件事的老写法,
|
||||||
|
还要自己管一个执行器;后者是一个第三方包,而本库的核心没有运行时依赖,为一个存储实现引一个
|
||||||
|
包不值。代价是 `to_thread` 用的是默认线程池(上限随 CPU 数走),并发运行很多时写入会排队——
|
||||||
|
但每次写只占用线程几毫秒,而每一步中间隔着一次几百毫秒的模型调用,排不到那儿去。真排到了,
|
||||||
|
那说明该换一种存储形态了。
|
||||||
|
|
||||||
## 决策六:运行开始记录用独占创建兜住跨进程撞车
|
## 决策六:运行开始记录用独占创建兜住跨进程撞车
|
||||||
|
|
||||||
@@ -129,6 +195,12 @@
|
|||||||
`run` 在开工前会先 `read_log` 判断这个标识有没有日志,但那是**先读后写**,两个进程同时读到
|
`run` 在开工前会先 `read_log` 判断这个标识有没有日志,但那是**先读后写**,两个进程同时读到
|
||||||
空、同时开始写的窗口它挡不住。独占创建把这个窗口关掉,代价是一个标志位。
|
空、同时开始写的窗口它挡不住。独占创建把这个窗口关掉,代价是一个标志位。
|
||||||
|
|
||||||
|
**它只挡住「两个进程都在新开一次运行」这一种。** 续跑不写运行开始记录,所以两个进程同时续跑
|
||||||
|
同一个标识挡不住——那时两条交错的记录序会让下一次恢复判成日志损坏。这一档现在没有跨进程的
|
||||||
|
机器保证,登记为已知缺口;进程内那一半由存储自己按运行标识加锁挡住(一条记录可能由不止一次
|
||||||
|
`os.write` 写完,而 `O_APPEND` 只保证每一次 `os.write` 的追加位置原子,保证不了一条逻辑行整体
|
||||||
|
原子)。真要跨进程挡,得引一把文件锁或者让调用方自己排他,那是它的编排层该管的事。
|
||||||
|
|
||||||
两个进程同时跑同一个运行标识的后果很具体:两条交错的记录序会让恢复读到同一步的两条意图,
|
两个进程同时跑同一个运行标识的后果很具体:两条交错的记录序会让恢复读到同一步的两条意图,
|
||||||
按 `_recovery` 的判定那是「日志被并发写过」,于是这次运行从此续不了——而两边的模型调用都
|
按 `_recovery` 的判定那是「日志被并发写过」,于是这次运行从此续不了——而两边的模型调用都
|
||||||
已经花过钱了。
|
已经花过钱了。
|
||||||
|
|||||||
@@ -8,13 +8,38 @@
|
|||||||
**触及** `../../src/polyloop/adapters/`,以及 `../../tests/integration/`——那一层到现在还是空的,
|
**触及** `../../src/polyloop/adapters/`,以及 `../../tests/integration/`——那一层到现在还是空的,
|
||||||
因为「连真 PolyGateway 的是 integration」(`../../CLAUDE.md` §1.9),而在这之前没有任何东西连它。
|
因为「连真 PolyGateway 的是 integration」(`../../CLAUDE.md` §1.9),而在这之前没有任何东西连它。
|
||||||
|
|
||||||
|
## 读本文需要的几个名字
|
||||||
|
|
||||||
|
**scope** 是网关里一组模型源的命名分组,也是它的治理单位——限流、熔断、缓存的账都按 scope
|
||||||
|
记。一次装配对应一个 scope。
|
||||||
|
|
||||||
|
**源(`SourceConfig`)** 是这个分组里的一个具体端点:一组「供应商 + 地址 + 密钥 + 模型名 +
|
||||||
|
超时」。**多个源可以指向同一个模型**(同一个模型在两家中转上各配一份),网关在它们之间做
|
||||||
|
选择、限流与故障切换。
|
||||||
|
|
||||||
|
**恒定采样参数(`extra_body`)** 是配置里写死、每次调用都并进请求体的那些(`temperature`、
|
||||||
|
`top_p` 之类),相对的是每次调用可以覆盖的那一层。**推理开关(`enable_thinking`)** 决定要不要
|
||||||
|
往请求体里注入开启/关闭推理的参数。两者的共同点是**它们会改变真正发出去的请求体**。
|
||||||
|
|
||||||
## 这份适配器要跨的那条缝
|
## 这份适配器要跨的那条缝
|
||||||
|
|
||||||
一边是本库的 `ModelCall` 与 `ModelReply`:消息是内容块序列、绑定是字符串映射、失败以异常
|
本库这一侧的接缝是 `polyloop.ports.ModelClient`,两个方法:
|
||||||
表达。另一边是网关的 `GatewayClient.chat`:消息是 `list[dict[str, Any]]`、返回一个有二十来个
|
|
||||||
字段的 `LLMResponse`、失败抛一族它自己的异常。
|
|
||||||
|
|
||||||
**缝两边的形状都不归我们定**,所以这份文档定的全是「怎么对上」,不是「该长什么样」。
|
```python
|
||||||
|
async def call(self, call: ModelCall) -> ModelReply: ...
|
||||||
|
def parameters(self) -> Mapping[str, str]: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`ModelCall` 有五个字段:消息序列、本次运行内的调用序号、运行标识、库预分配的结果标识、以及
|
||||||
|
项目自己的绑定。`ModelReply` 只有三个:调用标识、可见回复、推理段。
|
||||||
|
|
||||||
|
另一边是网关的 `GatewayClient.chat`:消息是 `list[dict[str, Any]]`,返回一个有二十来个字段的
|
||||||
|
`LLMResponse`,失败抛一族它自己的异常。
|
||||||
|
|
||||||
|
**缝两边的形状都不归我们定**,所以这份文档定的全是「怎么对上」,不是「该长什么样」。下面
|
||||||
|
决策三管消息、决策四管绑定;**中间那三个字段一个都不往下传**——调用序号、运行标识、结果标识
|
||||||
|
都是本库自己的坐标,网关有它自己的调用标识,两边靠那个标识连表(见文末)。项目想让网关按
|
||||||
|
运行分组,把它要的那个键放进绑定里,那条路是通的。
|
||||||
|
|
||||||
## 决策一:收一个已经装配好的客户端,不自己装配
|
## 决策一:收一个已经装配好的客户端,不自己装配
|
||||||
|
|
||||||
@@ -34,9 +59,17 @@ GatewayModelClient(client=..., settings=...)
|
|||||||
`parameters()` 要回答「这次运行用的是哪个模型配置」,续跑时逐字段比对。但**客户端不公开
|
`parameters()` 要回答「这次运行用的是哪个模型配置」,续跑时逐字段比对。但**客户端不公开
|
||||||
它的源列表与 scope**(构造时收下,只留在内部),所以从客户端本身问不出这个答案。
|
它的源列表与 scope**(构造时收下,只留在内部),所以从客户端本身问不出这个答案。
|
||||||
|
|
||||||
于是适配器同时收那份 `GatewaySettings`,从它的 `scope` 与 `sources` 里读出来。每个源报四样:
|
于是适配器同时收那份 `GatewaySettings`,从它的 `scope` 与 `sources` 里读出来。每个源报**五样**:
|
||||||
源名、供应商、模型名,以及会改变请求体的那两项——恒定采样参数与推理开关。
|
源名、供应商、模型名,以及会改变请求体的那两项——恒定采样参数与推理开关。
|
||||||
|
|
||||||
|
**判据是两条,不是一条。** 后两项进来是因为它们改变真正发出去的请求体;前三项进来是因为它们
|
||||||
|
决定**这次调用打到哪儿、打的是什么**——源名与供应商不改请求体,但它们一变,同一个模型名背后
|
||||||
|
可能是另一个端点、另一家中转,而那足以让两批数据不可比。
|
||||||
|
|
||||||
|
**超时、重试次数、限流阈值这些不进。** 它们改变的是「失败了怎么办」,不改变「成功时模型看见
|
||||||
|
什么、回了什么」;把它们算进模型身份,调一次超时就会让所有在跑的运行续不上,而那次调整跟
|
||||||
|
可复现性无关。
|
||||||
|
|
||||||
**不用网关内部那个 `build_model_fingerprint`。** 它确实算的是「模型身份」,但它不在网关的
|
**不用网关内部那个 `build_model_fingerprint`。** 它确实算的是「模型身份」,但它不在网关的
|
||||||
`__all__` 里,用它就得从子模块 import,而那是它的内部布局、他们重排一次我们就断。更要紧的是
|
`__all__` 里,用它就得从子模块 import,而那是它的内部布局、他们重排一次我们就断。更要紧的是
|
||||||
**它是为缓存键设计的**:它按模型名去重(多个源同一个模型算一份),因为缓存怕的是「不同配置
|
**它是为缓存键设计的**:它按模型名去重(多个源同一个模型算一份),因为缓存怕的是「不同配置
|
||||||
@@ -55,13 +88,21 @@ docstring,让传参的人看得见。
|
|||||||
所以把它们按顺序拼起来——不加分隔符,因为块之间本来就没有分隔符这个概念,加了就是往模型看见
|
所以把它们按顺序拼起来——不加分隔符,因为块之间本来就没有分隔符这个概念,加了就是往模型看见
|
||||||
的文字里塞东西。
|
的文字里塞东西。
|
||||||
|
|
||||||
|
**撞到不是文本块的块就报错,不跳过也不塞占位串。** 跳过的后果是那一块静默地不进请求——模型
|
||||||
|
看不见一张图,却照常回一段话,而轨迹上看不出少了东西。报错至少把「这个适配器还不认得这种块」
|
||||||
|
摆在明面上。
|
||||||
|
|
||||||
将来有图片块时,这里改成网关/供应商的多模态数组形态。**那是加分支,不是改签名**,正是
|
将来有图片块时,这里改成网关/供应商的多模态数组形态。**那是加分支,不是改签名**,正是
|
||||||
`0003` 决策六把内容定成序列而不是裸字符串换来的。
|
`0003` 决策六把内容定成序列而不是裸字符串换来的。
|
||||||
|
|
||||||
|
**代价照实认下:两个相邻文本块拼起来会变成连写。** 块之间没有分隔符这个概念,所以库不能替
|
||||||
|
调用方补一个空格——真要空格,那是解释器或者上下文装配那一侧该写进块里的。这条和决策四是同一个
|
||||||
|
态度:库不往模型看见的文字里塞自己的东西。
|
||||||
|
|
||||||
## 决策四:绑定里只有网关认得的那两个键会传下去,其余留给参数快照
|
## 决策四:绑定里只有网关认得的那两个键会传下去,其余留给参数快照
|
||||||
|
|
||||||
`ModelCall.binding` 是项目自己的坐标(某个下游有五维),网关只有 `session_id` 与
|
`ModelCall.binding` 是项目自己的坐标(某个下游有五维),网关只有 `session_id` 与
|
||||||
`parent_card_id` 两个槽位放得下这类东西。适配器把这两个键传下去,其余的键**不传**。
|
`parent_call_id` 两个槽位放得下这类东西。适配器把这两个键传下去,其余的键**不传**。
|
||||||
|
|
||||||
**这不是静默丢弃。** 绑定的首要消费者是参数快照——它的全部键值都进运行开始记录
|
**这不是静默丢弃。** 绑定的首要消费者是参数快照——它的全部键值都进运行开始记录
|
||||||
(`0006` 决策三),续跑时逐字段比对。也就是说那些键已经被记下来了,只是网关那边没有对应的
|
(`0006` 决策三),续跑时逐字段比对。也就是说那些键已经被记下来了,只是网关那边没有对应的
|
||||||
@@ -77,8 +118,10 @@ docstring,让传参的人看得见。
|
|||||||
——网关的异常类名(`AllSourcesExhausted`、`CircuitOpenError`、`GovernanceBackendError`……)
|
——网关的异常类名(`AllSourcesExhausted`、`CircuitOpenError`、`GovernanceBackendError`……)
|
||||||
本身就是最有用的那部分信息,翻译成我们自己的名字只会把它盖掉。
|
本身就是最有用的那部分信息,翻译成我们自己的名字只会把它盖掉。
|
||||||
|
|
||||||
**重试尤其不能做。** 网关内部已经有重试、退避、换源、熔断,外面再套一层会让两套预算重叠计费
|
**重试尤其不能做。** 网关内部已经有重试、退避、换源、熔断,外面再套一层会让两套预算重叠计费。
|
||||||
——那正是网关自己在 1.1.1 里修掉的那类 bug。
|
那不是假想:网关 1.1.1 修的正是这一类——它的「卡死判定」窗口与重试预算同时对真实尝试计时,
|
||||||
|
而前者更小,于是配了三次重试的调用一次都用不上就被判死,且没有任何报错。两套预算叠在同一段
|
||||||
|
时间上,总有一套先耗尽,而先耗尽的那套说了算。
|
||||||
|
|
||||||
`asyncio.CancelledError` 同样原样穿出去,它继承 `BaseException`,不会被任何 `except Exception`
|
`asyncio.CancelledError` 同样原样穿出去,它继承 `BaseException`,不会被任何 `except Exception`
|
||||||
接住。
|
接住。
|
||||||
@@ -88,6 +131,10 @@ docstring,让传参的人看得见。
|
|||||||
`LLMResponse.call_id` 的类型是 `str`,而本库的 `ModelReply.call_id` 是 `str | None` 且**绝不为
|
`LLMResponse.call_id` 的类型是 `str`,而本库的 `ModelReply.call_id` 是 `str | None` 且**绝不为
|
||||||
空串**——空串是个看起来合法的键,连表时静默匹配不上。所以拿到空串就映射成空值。
|
空串**——空串是个看起来合法的键,连表时静默匹配不上。所以拿到空串就映射成空值。
|
||||||
|
|
||||||
|
**这是防御,不是常规路径。** 正常情况下网关每次调用都会给出一个标识;空串意味着它在记账之前
|
||||||
|
就失败了,而那种情况通常直接抛异常、走不到这里。留这一下是因为两边的类型不同宽——它那边是
|
||||||
|
`str`,本库这边把「没有」和「空串」分得开,而分得开的那个区别只有在这里才落得下去。
|
||||||
|
|
||||||
## 留给后续的
|
## 留给后续的
|
||||||
|
|
||||||
**响应里那些字段本库不带走,靠调用标识连过去。** 网关的响应有二十来个字段(用量、延迟、
|
**响应里那些字段本库不带走,靠调用标识连过去。** 网关的响应有二十来个字段(用量、延迟、
|
||||||
|
|||||||
@@ -170,6 +170,13 @@ dissect 有一档实验要在同一时刻用不同的注入内容跑同一批题
|
|||||||
PolyLoop 的日志按运行标识分文件(`../design/0011-jsonl-run-store.md` 决策一),所以那五维要
|
PolyLoop 的日志按运行标识分文件(`../design/0011-jsonl-run-store.md` 决策一),所以那五维要
|
||||||
拼进运行标识;少一维的表现不再是覆盖,而是「日志里有别人的记录」,恢复会判成日志损坏。
|
拼进运行标识;少一维的表现不再是覆盖,而是「日志里有别人的记录」,恢复会判成日志损坏。
|
||||||
|
|
||||||
|
**题目那一维不保证是安全字符,编码方式由 dissect 自己定。** PolyLoop 自带的那份存储要求运行
|
||||||
|
标识只含字母、数字、点、下划线与连字符——它同时是文件名,而库不做转义(转义之后文件名就不再
|
||||||
|
等于标识,按标识去目录里找文件这个用法就断了)。所以 `item_id` 里要是有中文、空格或标点,得
|
||||||
|
由 dissect 选一种**单射**的编码把它变过去(哈希、百分号编码、自己维护一张映射表都行)。
|
||||||
|
**选哪种归 dissect**,因为只有它知道那份标识事后要怎么被人认出来——而它的分析方式是让模型去翻
|
||||||
|
文件,所以「一眼认得出是哪一次」这件事对它有实际价值,哈希成一串十六进制未必合适。
|
||||||
|
|
||||||
**这条日志和 `Rollout.to_jsonl` 那份轨迹是两样东西,不要混。** 前者是意图日志,记的是「准备
|
**这条日志和 `Rollout.to_jsonl` 那份轨迹是两样东西,不要混。** 前者是意图日志,记的是「准备
|
||||||
做什么、做完了没有」,为的是崩了能续;后者是产物,记的是逐步轨迹,给反思模型读。前者由库写,
|
做什么、做完了没有」,为的是崩了能续;后者是产物,记的是逐步轨迹,给反思模型读。前者由库写,
|
||||||
后者迁移后由 dissect 自己从 `RunResult.steps` 重组(见下一条)。
|
后者迁移后由 dissect 自己从 `RunResult.steps` 重组(见下一条)。
|
||||||
|
|||||||
Reference in New Issue
Block a user