Files
PolyLoop/CLAUDE.md
T
iomgaa 8f5caa0924 docs: CLAUDE.md 补上发布的判据与代理这条环境事实,清掉三处过期记载
§1.10 那条指向的发布指南现在有了,同时补两件调研 PolyGateway 时核实到的:它当年那次
补救只写了文档没有回补上传,所以 1.0.6 与 1.1.0 到今天仍然不在 registry 上,而 dissect
的依赖恰好钉在那个空区间里装不上——记下教训不等于修好问题;以及发布完成的判据是外部可见
结果不是本地步骤跑通,1.1.2 三步全绿而包页面是空白的。

§4 新增一条:这台机器的代理到不了外面,访问实验室 Gitea 的命令都要绕开,否则失败看起来
像服务器挂了。过期记载三处:conda 环境早就建了、契约测试早就写了、十个模块早就不是空骨架。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 00:12:46 -04:00

165 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PolyLoop
实验室共用的 Agent 执行内核。治理单位是**一次运行**:围绕一个目标的有界多轮「模型决策 → 动作 → 观察」循环,含预算、停止语义、取消、逐步轨迹与 Skill 注入。一次模型调用本身不归它管,那是 PolyGateway 的治理单位;PolyLoop 用 PolyGateway 的顶层公共 API,不重建一套模型治理。
首批消费者是 dissect 与 GovDoc-SaaSCHSAnalyzer 是远期消费者。
回复用简体中文;代码与标识符用英文。**commit message 是「英文前缀 + 中文正文」**,形如 `feat(session): 停止判定顺序落成代码`(前缀是 `类型(范围)` 那套约定,范围可省)。
> **这是库,不是应用。** 它的 bug 会同时击穿所有下游项目,所以稳定性、并发正确性、防御校验、可观测与测试不为「简单」让步——YAGNI 仍然适用,但不削减健壮性。
> **能交给机器的就别靠自觉**——能写成 CI、ruff 规则、import-linter 契约或测试的就去写,写不出来的至少要能在 §3 那轮评审里被指出来。本文件本身只是给协作者(人和 AI)的上下文,不是强制层,真要拦住某个动作得靠 CI 或 hook。
> **本文件不放临时内容。** 会过期的东西(当前阶段、进行中的迁移、临时约定)放到它自己的权威处,这里只留一条指向那里的常青规则——否则过期条文会留在这里没人记得删。
---
## 0. 事实的解释权(冲突时按此裁决,**不要自行调和**)
| 这类事实 | 权威处 |
|---|---|
| **哪些事归本库管、哪些不归**,以及判据 | `research-wiki/explanation/scope.md` |
| 分层、依赖方向、模块边界 | `research-wiki/explanation/architecture.md`,由 `pyproject.toml` 的 import-linter 契约机器断言。九条依赖规则里有两条落不进契约(「不许 import 任何第三方」不是可枚举清单,「import 之后 `sys.modules` 里没有谁」是运行时事实),它们是 `tests/unit/` 里的测试 |
| 公共 API 的行为契约:一次 `run` 到底保证什么、边界条件怎么结算 | `tests/contract/` 的公共契约套件。它同时是任何新适配器的准入标准 |
| 公共类型的字段、不变量、枚举取值 | `src/polyloop/` 的代码与其测试。**不另写一份参考文档复述它们**——那份文档不重复代码的内容太少,而它腐烂的速度和代码一样快 |
| 每个下游项目要迁走什么、迁完算不算数 | `research-wiki/migrations/` 下对应那份 |
| 已定的决策及其理由 | `research-wiki/design/` 下相关编号最大的那份 |
| 某个机制、约束、坑为什么是这样 | `research-wiki/explanation/` |
| 其余查得到的事实:日志字段契约、遥测口径 | `research-wiki/reference/` |
| **当前进度:处在哪个阶段、哪些已完成** | `README.md` 的阶段清单 |
| 已发布的版本与每版改了什么 | `CHANGELOG.md` |
| 文档体系怎么组织、新文档该放哪 | `research-wiki/README.md` |
| 协作规则 | 本文件 |
**`reference/` 不在上表里,因为它不是任何东西的权威。** 那里的六个仓库和 `agent-core.md` 地位相同,都是参考资料。`agent-core.md` 是别人为本项目写的一份架构提案,它不是我们的设计,也不是常青文档——其中任何一条在被我们自己的 design doc 明确采纳之前都不作数。引用它时必须写成「agent-core.md 的说法是……」,不能写成「我们决定……」。代价是每次多写一句话,收益是不会长出「大家都以为这个决定已经做过了」的状态——那种状态在上表的裁决规则下最难修,因为它没有一个错的地方可以指。
`reference/` 只读、不入库,也不改。
**复述规则:论证可以复述,参数不许复述。**
同一个道理在几处各讲一遍是好事——人类读者希望在一份文档里把事情读懂,而不是在几份之间反复跳转,而论证不会漂移,最多某一处写得不如另一处好。但同一个**参数**(数字、路径、文件名、类型名、枚举取值、命令行的具体形状)只在上表的权威处出现一次,别处引用它:那种东西迟早会有一处被改、另一处没改。
代价是会出现另一种漂移:两处的**道理**打架(一处说「为了可复现所以冻结」,另一处说「为了省内存所以只留引用」)。机器查不出来,靠 §3 那轮独立评审兜。展开见 `research-wiki/README.md`
**为什么冲突时禁止自行调和。** 把两边捏合成一个折中说法,看起来是负责任,实际上会生出第三个没人认过的版本,而且把「有一处已经漂移了」这个真正需要修的信号盖掉了。按表裁决则相反:它逼你去改错的那一处,漂移当场被消灭。
**本文件只管协作约定,不管项目事实。** 与上表任一文件冲突时以那边为准,并顺手把本文件改对。改本文件本身不需要请示——但如果改的是 §1 的硬约束或 §2 的人类门,先说一声再动。
## 1. 硬约束
1. **零业务假设。** 库内禁止出现下游的业务词汇(公文、审核点、超声、CHS、benchmark、实验轮次、得分)与业务 fixtures;扩展点一律用 Protocol。三个下游的领域互不相交,一个业务词进来就等于替其中一个项目做了另外两个不需要的假设。这类假设很难删——它会长出配套的字段、分支和测试,删的时候要一起动。
2. **不反向 import 任何下游项目。** 由 import-linter 契约断言。
3. **公共类型的字段只增不删不改名,新增字段必带默认值。** 三个下游各自 `pip install` 本库,改名会让已经在跑的代码直接 `ImportError` 或静默拿到默认值。要删要改就发新 major 并写迁移指引。
4. **持久化结构的 schema 变更走显式版本,不靠默认值补齐。** 会被下游存进数据库或实验数据集的结构(运行结果、逐步轨迹)必须带独立的 schema 版本,读到未知 major 直接失败。dissect 的轨迹是论文实验数据,一次静默的默认值填充会把「这件事没发生过」改写成「发生了但值为空」,而这种损坏要到统计阶段才暴露,那时已经分不清哪些行是真的。
5. **模型调用一律走 PolyGateway**(实验室共用库)。不在本项目里另写重试 / 限流 / 熔断 / 缓存 / 遥测。缺能力就给 PolyGateway 提 PR。
6. **`asyncio.CancelledError` 永不捕获吞没。** 取消要能穿过模型调用与环境执行,in-flight 资源在 `finally` 释放。吞掉它的后果不是「取消失败」这么直白——是容器租约、连接和临时目录持续泄漏,而且一声不吭。
7. **禁止吞掉错误**`except Exception: pass` 及其跨行形态)。由 ruff `S110` / `E722` 断言。
8. **测试绑行为,不绑实现。** 不写「断言某个内部类有哪些方法」这类测试——它只会让重构连坐。
**公共 Protocol 的签名是例外**:它本身就是对下游的承诺,不是实现细节,所以 `tests/contract/` 断言它是应该的。判据是这个名字有没有对外承诺过——承诺过的改名是破坏性变更(§1.3),断言它就是在守那条承诺;没承诺过的改名只是重构,断言它就是在拖后腿。
**断言某个名字「不存在」也是允许的**,用来守住一次删除决策。一个已经被删掉的字段没法被重命名,拖不动测试。代价是它守的只是名字不是概念——换个名字把同一个概念加回来,测试照样绿,所以理由必须同时写在被删字段所在类型的 docstring 里。
9. **测试分层按「依赖什么」定,不按「叫什么」定。** 用测试替身的是 unit,连真 PolyGateway 的是 integration,打真实模型网关的是 e2e,验证公共 Protocol 行为一致性的是 contract。按名字分层的话,改个函数名就要挪测试文件;按依赖分,只要这个测试还是不连外部服务,它就一直待在原地。四层之间更细的界线在搭测试框架那个阶段定,现在不必较真。
10. **发布 = 合并 + push + tag + 构建 + 上传 registry + 验证已发布。只 bump 版本号不叫发布。** 教训来自 PolyGateway1.0.6 与 1.1.0 都完成了版本号 bump 与 CHANGELOG,却从未上传,registry 长期停在 1.0.5——下游 `pip install` 拿不到任何修复,且无人发现。**那次的补救只写了文档、没有回补上传,所以那两个版本到今天仍然不在 registry 上**,而 dissect 的依赖恰好钉在那个空区间里、装不上。这说明记下教训不等于修好问题。完整步骤与全部已知的坑见 `research-wiki/guides/releasing.md`
**判据是外部可见结果,不是本地步骤跑通**:收尾要以下游视角逐一打开产物——registry 包页面的正文与仓库链接、仓库的 Releases 页、装完之后包里的文件。PolyGateway 的 1.1.2 三步全绿,包页面却是空白的。
11. **动手前先看 README 的阶段清单。** 不要为了还没到的阶段提前写大量代码,也不要为假设中的工作量预先埋好一堆结构——这就是 §6 YAGNI 的意思,只是在阶段这个尺度上再说一次。
## 2. 人类门(仅以下需要用户批准,其余自行判断)
| 场景 | 为什么 |
|---|---|
| 改公共 API:公共类型的字段、Protocol 签名、停止原因的取值、停止判定顺序、持久化 schema 版本 | 三个下游按它写代码,错了会静默扩散,且改动成本随时间指数上升。**先写 design doc,等确认再动手** |
| 任何外部可见动作:push、开 / 关 PR、动别人的分支 | 涉及协作者 |
| 发布(含打 tag 与上传 registry) | 下游一旦装上就收不回来 |
| 删除或覆盖既有数据 | 不可回滚 |
**「改公共 API」指的是改变已有承诺的形状**——新增一个可选组件并同时补上它的契约测试,属于「写代码 + 补测试」,自行判断即可。区别在于前者会让已经在用的调用方静默失败。
其余(写代码、跑测试、写文档、重构、补测试、写 design doc 草稿)**无需请示;何时先讨论设计由你判断**。
**外部 PR 一律不直接 merge。** 逐段审阅:符合本仓库规范的代码直接复用,不符合的按规范改写;无论哪种,落地结果必须与原 PR **功能等价**,并在提交信息里标明来源 PR 与作者。理由是本仓库靠一套机器可检查的约定维持一致性,而外部分支不在这套约定下产生。
## 3. 审查规则
**四类高风险产物必须 Codex 对抗审查**`/codex:rescue --fresh --wait`):
1. 公共类型与 Protocol 签名的任何变更;
2. 主循环的停止判定与预算结算;
3. 取消传播,以及并发 Session 之间的状态隔离;
4. 持久化结构的 schema 演进与反序列化。
这四类的共同点是**错了不会当场炸**。签名改错要等下游升级那天才发现;停止判定顺序错了,「恰好在最后一步做完」会被记成「预算耗尽」,而两者的轨迹长度一模一样;取消漏掉一处只表现为资源占用慢慢往上涨;schema 靠默认值补齐要到统计阶段才看得出来。这类问题人眼复核的命中率很低,因为它们没有失败现场。
Codex 是 OpenAI 的编码模型,本仓库通过 `codex` 插件调用它。**之所以必须换它、而不是再开一轮自家 subagent**:同一个模型的盲区是一致的,它审自己写的东西,会以同样的理由漏掉同样的问题。换一个不同来源的模型才可能戳破这层偏见,也能挡住单个模型偶尔的抽风。「对抗」指的就是让它专门去挑毛病,而不是让它确认我们做得对。
**其余代码**完成后至少一轮**独立 subagent 新鲜上下文审**prompt 只给 diff、验收标准和相关文档,**不给实现时的推理过程**。给了推理过程,它会顺着我们的思路复核一遍,只能验证「按这个思路做得对不对」,验证不了「这个思路本身是不是错的」。
**文档大改必须过一轮独立 subagent 的「硕士生阅读」。** 触发条件:新写一份文档、重写既有文档的整节、或单份文档改动超过约 100 行。开一个新鲜上下文的 subagent,**只给它改后的文档,不给我们的讨论过程、不给相关代码**——它必须纯靠文档读懂。**先问它读的时候发生了什么,再问它查到了什么**,两问的顺序不能反。
第一问是**阅读行为**:哪几段你跳过去了、读到哪儿开始走神、合上文档能不能把这套东西复述一遍。**跳读和走神是行为,不是意见**,所以它们不受下面那条「不采纳风格建议」的约束——一个读者跳过了某一段,那就是一个关于这段文字的事实。这一问是 CHSAnalyzer 踩出来的:那边的文档里有整段只在讲文档自己(「这一节把整份文档串成一个故事」这类),前面跑过两轮的审查一条都没报,因为当时的 prompt 明令它不许报这类,于是这一整类问题对这道闸天然不可见。
第二问才是三类具体问题:**哪句话读不懂、缺了什么前置知识**;**哪个决策只写了结论没写理由**;以及**同一个参数(数字、路径、类型名、命令形状)在两处取值不同**。前两类主观,但那是它们的性质;第三类是确定性判据,报了就是真的。格式、措辞、结构建议一律不采纳——每次都能挑出十条建议,等于没有建议,这轮评审很快就会被跳过。参数一致性不另开一轮检查,也不写成脚本:按 §0 的复述规则参数本来就只在权威处出现一次,撞车机会很少,专设一道检查会长期空转,而空转的检查很快就会被跳过。
**审查反馈只采纳影响正确性或明确需求的项**;风格类建议自行取舍,防过度工程。结论有分歧时,**以「能否指出具体失败场景」为准**。
## 4. 环境与运行
- **这台机器设了 `http_proxy` / `https_proxy`,指向一个到不了外面的本地代理。** 凡是访问实验室 Gitea 的命令(上传发布产物、验证已发布、从私有源装包)都得绕开它,否则失败的形态是网关错误而不是「代理有问题」,很容易被当成服务器挂了。具体命令在 `research-wiki/guides/releasing.md``README.md` 的安装一节。
- Conda 环境 `PolyLoop`Python 3.11。3.11 不是选出来的,是被下游钉死的:dissect 和 GovDoc-SaaS 都跑在 3.11,一个库不能要求比它的消费者更高的版本。
- **Python 命令一律用这个形状**:`PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop <cmd>`。conda 和 Python 各缓冲一层,两层都得拆:只加 `--live-stream` 或只加 `-u` / `PYTHONUNBUFFERED` 都仍然全程无输出,直到进程结束才一次性吐出。六种组合的实测与原理见 `reference/CHSAnalyzer/research-wiki/explanation/conda-run-output-buffering.md`(同一台机器、同一套 conda,结论直接适用)。
- **超过约一分钟的命令(测试套件、压测、真实网关回归)必须放进 tmux 跑**,不要阻塞在前台,也不要只丢进后台。tmux 会话人和 AI 都能 attach,可以一起看同一份实时输出、随时中断。会话按用途命名(如 `polyloop-e2e`),跑完不要急着 kill,留着给人复查。
- **长跑命令末尾不得接管道。** `pytest ... | tail` 的退出码来自管道最后一节,于是失败的测试跑会报成 exit 0。要判断完成用 `wait` 或轮询 PID**不要用 `pgrep -f "<完整命令串>"`**——它会匹配到自己,形成永不结束的等待。这两条是 PolyGateway 实测撞出来的,两种失败都以「看起来还在跑」的形态呈现,从外部区分不了。
- **本库不部署,也不跑模型推理。** 所有模型调用经 PolyGateway 出去(§1.5)。**这台机器是和别人共用的**,本仓库现在没有任何用得着 GPU 的代码;**但只要哪天有了,那条命令就必须显式加 `CUDA_VISIBLE_DEVICES=<idx>`**,省略会自动选卡,占掉别人正在用的显卡。
## 5. 目录说明(★ = 已存在,其余为规划)
只列需要解释的。`src/``tests/``.github/workflows/` 这类看名字就知道装什么的不列。
```
★ reference/ 参考资料:六个仓库 + agent-core.md
只读、不改、不入库,且不是任何东西的权威(§0)
★ research-wiki/README.md 文档体系怎么组织、新文档该放哪
★ research-wiki/design/ 动工前的方案与权衡,只增不改;决策变更 = 新写一份标 supersedes
★ research-wiki/explanation/ 为什么这样设计(常青,须写明更新触发点)
★ research-wiki/migrations/ 每个下游项目迁走什么、迁完算不算数(常青)
★ research-wiki/guides/ 怎么做某件事:发布、本地环境、排障
★ research-wiki/reference/ 查得到的事实:日志字段契约、遥测口径
(公共类型和枚举取值不在这里,权威见 §0 表格)
★ research-wiki/scratch/ 一次性草稿。进 git,但由人在每轮工作会话结束前清理(AI 不要自动删)
★ tests/contract/ 公共 Protocol 的行为一致性套件,是那份契约的权威(§0),
也是任何新适配器的准入标准
★ tests/e2e/ 打真实模型网关,会产生真实费用。默认不跑,两道闸见 .env.example
★ src/polyloop/ 库本体,十个模块
```
常青层与记录层的分界、各类的更新触发点、`scratch/` 那条人工清理规则的已知风险,都在 `research-wiki/README.md`
硬性规则:根目录不得出现 `.py`;禁止 `helpers/``common/``shared/``misc/``utils/` 这类目录名——它们的职责是「剩下的东西」,一句话说不清职责就没有边界,最后什么都往里塞。
## 6. 代码与文档规范
- **YAGNI**:不写当前用不到的代码。但健壮性(并发控制、防御校验、可观测、错误隔离、测试)**是当前需要**,不在削减之列。抽象只在真正易变 / 需替换 / 需造测试替身的接缝处引入。
- **显式优于隐式**:公共函数完整类型注解;依赖注入,不从全局偷取;不用默认参数掩盖关键逻辑。这条在库里比在应用里重一档——下游看不到实现,只能靠签名和类型判断该传什么。
- **一切外部输入校验后使用**:模型返回、适配器返回、配置都算外部输入。校验用显式异常,**`assert` 只用于内部不变量**,不承担生产校验——Python 的 `-O` 会把 assert 整条移除,下游用 `-O` 跑的那天校验就静默消失了。
- **文档的目标读者是「没参与过我们讨论的相关领域硕士生」。** 自造词在首次出现处就地解释;**每个设计决策都要写清楚「为什么这么定」**——只写结论不写理由的文档,过几天连我们自己都看不懂。
- **以人类可读为准,不以信息密度为准。** 禁止:一句话套三层因果;用箭头链(`A → B → 失败`)代替句子;把论证塞进表格单元格(表格只放事实和数字,论证放正文段落)。
- **不写导航句,也不先宣布自己要讲什么。** 不告诉读者该按什么顺序读、哪一节可以跳过、这一节接下来要讲什么。该讲的直接讲——「这一步反直觉,得解释」删掉之后解释还在那儿,反不反直觉读者自己会判断。**指路是另一回事,照写不误**:「见第五节」给的是位置,不是对内容的预告。
这条靠自觉,而且照着它也写得出合规的废话;真正管用的是 §3 那一问。**不要试图把它写成机器检查**——CHSAnalyzer 实测过:「本节」这个词在三份常青文档里出现十处是合法的指路、七处是自述,一半误报的检查活不过两周。
- **常青文档只用「陈述系统」这一种语气。** 句子的主语是系统里的东西(这个字段、这个策略、这条规矩),不是「这份文档」「这一节」「这里」。一旦动词变成写作动作——不复述、列出来、说清楚、正面写、免得读者——就走音了,**哪怕那句话本身有道理**。改法是把主语换回系统:「代价要说清楚:X」写成「代价是:X」。
这条和上一条是同一族的两个种:上一条是**先宣布自己要讲什么**,这条是**解释自己为什么这么写**。**两条都不能用「删掉之后信息有没有少」来判**——那种句子往往真的带着信息,按内容判会把它留下来,而它照样读着别扭。判据在语气,不在内容。
- **代码里的 docstring 和注释同理,判据是「读这段代码的人不知道就会写错什么」。** 不要把 design doc 的论证整段抄进来——那是 `design/` 的职责,指过去一行就够。约束某一处代码的话就写在那一处。**例外是那些 design doc 点名要求写进代码的**,以及 §1.8 那种「被删掉的字段为什么删」。
- 文档长度上限:`design/``explanation/` 下的单份文档 ≤600 行,`guides/` ≤400 行。这两个数没有理论依据,取的是「一次能读完、不必分几天啃」的经验值。**超了不是「必须拆」,是「必须停下来检查这份文档是不是在讲不止一件事」**——确认是就拆,确认不是就在文档开头写一句为什么不拆。`design/` 判断可以再宽一些,因为它是「我想知道当初为什么这么定」时跳进去看**某一个决策**的,很少有人从头读到尾。`reference/``migrations/` 不设上限——字段表、删除清单本来就该写全,砍长度只会让它变得不可信。
## 7. 工作方式
- **交付被请求的范围。** 常规判断自己做;只有当不同理解会导出实质不同的工作时才来问。觉得请求有问题就用一两句说出来,然后按原样继续做,**不要悄悄地缩小、放大或改造它**。
- **报告进展前,逐条对照本次会话真实的工具结果。** 只报告拿得出证据的部分;没验证的明说没验证。测试挂了就贴输出;跳过的步骤就说跳过了;做完并验证了就平实地说清楚,不要模糊其辞。
- **不做没让做的事**:不顺手重构、不为假设中的未来需求加抽象。修 bug 不需要顺带清理周边。
- **不建防御性备份分支。** 想留个后路的心情可以理解,但分支一多就没人认得出哪条还有用,最后谁都不敢删。git 本来就留着历史,需要回退随时回得去。
- **能压成一段结论的活尽量交给 subagent,必须和别处约束咬合的活自己做。** 判据是产出的形状:「读一批材料、回来给个清单」属前者——调研某处怎么实现的、跨几份文档核对结论有没有回写、大范围搜索某个东西在哪;「写一段要同时压着十条约束的代码」属后者,交出去只会收回一段看着对、细节全错的东西,而那类错是静默的。判断一条审查发现成不成立、写 design doc、做取舍、和人对话,同样自己做。**委托出去的活要求交证据不交判断**:事实要带 `文件:行号` 或命令原始输出,并抽查两三条校准这一份可不可信——抽查错一条整份都不采纳,因为它已经证明会编。
- **持续往下做,不要每完成一件事就停下来问「要不要继续」。** 只在两种情况停:撞上 §2 那张表里的人类门,或者不同理解会导出实质不同的工作而你判断不了。除此之外做完一件接着做下一件,做完一起报。每做完一步就问一次,等于把「决定下一步做什么」这件本该由你承担的事推回给人,而人手上的上下文比你少。
## 8. 对话
说人话。像同事聊天那样一次说一件事,别把一轮回复写成报告。你是我的合作者,不是一个机器,不要把一大堆内容直接甩给我自己分析,这是推卸责任。我们的目标是一起通力合作开发好这个项目。
问什么答什么,有判断直接讲。**这条管的是怎么说话,不是怎么干活**——别在一轮回复里把后面几步的推演一口气铺完,但活该往下做就往下做,什么时候停按 §7 那条。不要默认一些名词和你搜索到的内容我是一定知道的,你有讲解的义务。不要为了「扮演」专业刻意使用高信息的句子或者表述,这会显著降低可读性。
**要我做决定时,一次把决定需要的信息给全。** 具体说:总共几个问题、每个问题有哪些选项、你倾向哪个、以及哪些是你自己就能定的。**不许挤牙膏**——先讲三条、等我追问才补上剩下九条,这中间我是在信息不全的情况下做判断,等于白问。你看得到全部上下文,我看不到;你不列全,我就没有选的依据。