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:
@@ -8,13 +8,38 @@
|
||||
**触及** `../../src/polyloop/adapters/`,以及 `../../tests/integration/`——那一层到现在还是空的,
|
||||
因为「连真 PolyGateway 的是 integration」(`../../CLAUDE.md` §1.9),而在这之前没有任何东西连它。
|
||||
|
||||
## 读本文需要的几个名字
|
||||
|
||||
**scope** 是网关里一组模型源的命名分组,也是它的治理单位——限流、熔断、缓存的账都按 scope
|
||||
记。一次装配对应一个 scope。
|
||||
|
||||
**源(`SourceConfig`)** 是这个分组里的一个具体端点:一组「供应商 + 地址 + 密钥 + 模型名 +
|
||||
超时」。**多个源可以指向同一个模型**(同一个模型在两家中转上各配一份),网关在它们之间做
|
||||
选择、限流与故障切换。
|
||||
|
||||
**恒定采样参数(`extra_body`)** 是配置里写死、每次调用都并进请求体的那些(`temperature`、
|
||||
`top_p` 之类),相对的是每次调用可以覆盖的那一层。**推理开关(`enable_thinking`)** 决定要不要
|
||||
往请求体里注入开启/关闭推理的参数。两者的共同点是**它们会改变真正发出去的请求体**。
|
||||
|
||||
## 这份适配器要跨的那条缝
|
||||
|
||||
一边是本库的 `ModelCall` 与 `ModelReply`:消息是内容块序列、绑定是字符串映射、失败以异常
|
||||
表达。另一边是网关的 `GatewayClient.chat`:消息是 `list[dict[str, Any]]`、返回一个有二十来个
|
||||
字段的 `LLMResponse`、失败抛一族它自己的异常。
|
||||
本库这一侧的接缝是 `polyloop.ports.ModelClient`,两个方法:
|
||||
|
||||
**缝两边的形状都不归我们定**,所以这份文档定的全是「怎么对上」,不是「该长什么样」。
|
||||
```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()` 要回答「这次运行用的是哪个模型配置」,续跑时逐字段比对。但**客户端不公开
|
||||
它的源列表与 scope**(构造时收下,只留在内部),所以从客户端本身问不出这个答案。
|
||||
|
||||
于是适配器同时收那份 `GatewaySettings`,从它的 `scope` 与 `sources` 里读出来。每个源报四样:
|
||||
于是适配器同时收那份 `GatewaySettings`,从它的 `scope` 与 `sources` 里读出来。每个源报**五样**:
|
||||
源名、供应商、模型名,以及会改变请求体的那两项——恒定采样参数与推理开关。
|
||||
|
||||
**判据是两条,不是一条。** 后两项进来是因为它们改变真正发出去的请求体;前三项进来是因为它们
|
||||
决定**这次调用打到哪儿、打的是什么**——源名与供应商不改请求体,但它们一变,同一个模型名背后
|
||||
可能是另一个端点、另一家中转,而那足以让两批数据不可比。
|
||||
|
||||
**超时、重试次数、限流阈值这些不进。** 它们改变的是「失败了怎么办」,不改变「成功时模型看见
|
||||
什么、回了什么」;把它们算进模型身份,调一次超时就会让所有在跑的运行续不上,而那次调整跟
|
||||
可复现性无关。
|
||||
|
||||
**不用网关内部那个 `build_model_fingerprint`。** 它确实算的是「模型身份」,但它不在网关的
|
||||
`__all__` 里,用它就得从子模块 import,而那是它的内部布局、他们重排一次我们就断。更要紧的是
|
||||
**它是为缓存键设计的**:它按模型名去重(多个源同一个模型算一份),因为缓存怕的是「不同配置
|
||||
@@ -55,13 +88,21 @@ docstring,让传参的人看得见。
|
||||
所以把它们按顺序拼起来——不加分隔符,因为块之间本来就没有分隔符这个概念,加了就是往模型看见
|
||||
的文字里塞东西。
|
||||
|
||||
**撞到不是文本块的块就报错,不跳过也不塞占位串。** 跳过的后果是那一块静默地不进请求——模型
|
||||
看不见一张图,却照常回一段话,而轨迹上看不出少了东西。报错至少把「这个适配器还不认得这种块」
|
||||
摆在明面上。
|
||||
|
||||
将来有图片块时,这里改成网关/供应商的多模态数组形态。**那是加分支,不是改签名**,正是
|
||||
`0003` 决策六把内容定成序列而不是裸字符串换来的。
|
||||
|
||||
**代价照实认下:两个相邻文本块拼起来会变成连写。** 块之间没有分隔符这个概念,所以库不能替
|
||||
调用方补一个空格——真要空格,那是解释器或者上下文装配那一侧该写进块里的。这条和决策四是同一个
|
||||
态度:库不往模型看见的文字里塞自己的东西。
|
||||
|
||||
## 决策四:绑定里只有网关认得的那两个键会传下去,其余留给参数快照
|
||||
|
||||
`ModelCall.binding` 是项目自己的坐标(某个下游有五维),网关只有 `session_id` 与
|
||||
`parent_card_id` 两个槽位放得下这类东西。适配器把这两个键传下去,其余的键**不传**。
|
||||
`parent_call_id` 两个槽位放得下这类东西。适配器把这两个键传下去,其余的键**不传**。
|
||||
|
||||
**这不是静默丢弃。** 绑定的首要消费者是参数快照——它的全部键值都进运行开始记录
|
||||
(`0006` 决策三),续跑时逐字段比对。也就是说那些键已经被记下来了,只是网关那边没有对应的
|
||||
@@ -77,8 +118,10 @@ docstring,让传参的人看得见。
|
||||
——网关的异常类名(`AllSourcesExhausted`、`CircuitOpenError`、`GovernanceBackendError`……)
|
||||
本身就是最有用的那部分信息,翻译成我们自己的名字只会把它盖掉。
|
||||
|
||||
**重试尤其不能做。** 网关内部已经有重试、退避、换源、熔断,外面再套一层会让两套预算重叠计费
|
||||
——那正是网关自己在 1.1.1 里修掉的那类 bug。
|
||||
**重试尤其不能做。** 网关内部已经有重试、退避、换源、熔断,外面再套一层会让两套预算重叠计费。
|
||||
那不是假想:网关 1.1.1 修的正是这一类——它的「卡死判定」窗口与重试预算同时对真实尝试计时,
|
||||
而前者更小,于是配了三次重试的调用一次都用不上就被判死,且没有任何报错。两套预算叠在同一段
|
||||
时间上,总有一套先耗尽,而先耗尽的那套说了算。
|
||||
|
||||
`asyncio.CancelledError` 同样原样穿出去,它继承 `BaseException`,不会被任何 `except Exception`
|
||||
接住。
|
||||
@@ -88,6 +131,10 @@ docstring,让传参的人看得见。
|
||||
`LLMResponse.call_id` 的类型是 `str`,而本库的 `ModelReply.call_id` 是 `str | None` 且**绝不为
|
||||
空串**——空串是个看起来合法的键,连表时静默匹配不上。所以拿到空串就映射成空值。
|
||||
|
||||
**这是防御,不是常规路径。** 正常情况下网关每次调用都会给出一个标识;空串意味着它在记账之前
|
||||
就失败了,而那种情况通常直接抛异常、走不到这里。留这一下是因为两边的类型不同宽——它那边是
|
||||
`str`,本库这边把「没有」和「空串」分得开,而分得开的那个区别只有在这里才落得下去。
|
||||
|
||||
## 留给后续的
|
||||
|
||||
**响应里那些字段本库不带走,靠调用标识连过去。** 网关的响应有二十来个字段(用量、延迟、
|
||||
|
||||
Reference in New Issue
Block a user