架构评审 · Pi 云展 backend

寻找 deepening 机会

范围:apps/backend/src/ · 分支 dev @ 99b1be3 · 2026-07-02
shallow module
deep module
seam
leakage

12 个候选。每个都跑过 deletion test。用 CONTEXT.md 里的领域词汇 (Thread / Loose thread / Approval / watermark / CockpitEvent seam / turn)说话;用 LANGUAGE.md 的架构词汇(module / interface / seam / adapter / leverage / locality / shallow / deep)标记形态。 与 docs/adr/0001-0003 冲突的候选会标注。

1 · Provider Adapter seam:把 Pi runtime 装配收成一处

Strong ports & adapters 真实痛点
config.ts · client-pool.ts · credentials.ts · visibility.ts · routes.ts · agent-loop/summarizer.ts · naming.ts
before · 六处泄漏,每加一个 provider 都要改这六处
provider: string piRpcArgs() piChildEnv() credentials.ts client-pool visibility KNOWN_MODELS /api/models naming.ts (LLM) summarizer (haiku) 加 MiniMax 时上周就死在这张网上
after · 一个 provider seam, 每个 provider 一份 adapter
ProviderAdapter interface rpcArgs(cfg) · childEnv(cfg) · models[] · credentialSchema defaultModel · summarizeModel anthropic adapter minimax adapter future… callers 只见 provider.rpcArgs / provider.models
problem

"provider" 是根裸 string。装配 Pi 子进程要读 piProvider + 拼 piRpcArgs + 注 piChildEnv + 存 credentials + 报 /api/models + 校 visibility.KNOWN_MODELS——六处硬编码 anthropic 假设。加 MiniMax 需要同时改六处,且没有一处告诉你"下一个是啥"。上周回滚坐实。

solution

把"什么是一个 provider"提成 ProviderAdapter interface,anthropic 是第一个 adapter;MiniMax 加一个文件就够。config.piProvider 变 provider registry 的 key,其它模块从 providers.get(key) 拿字段——不再各自 if (cfg.piProvider === 'anthropic')

wins
  • locality:provider 差异集中在一个 adapter 文件
  • leverage:加 provider = 加一个 adapter,不动 6 处 caller
  • 两个 adapter 出现 = 真 seam(LANGUAGE 原则)
  • 可测:mock adapter 单测 client-pool 全流程
deletion test

删掉这个 adapter 层?六处硬编码复现。这就是 "complexity reappears across N callers" 的教科书信号。seam 该有。

2 · routes.ts 按域拆四片

Strong in-process 大文件
routes.ts (592 行)
before · 一个 592 行的 registerRoutes()
L66-160 · /api/config & /api/models & skills toggle
L204-267 · project CRUD
L269-339 · thread CRUD + batch summary
L341-366 · 文件上传
L368-448 · plan / artifact 读/下载
L450-481 · 项目文件树
L483-545 · thread meta 卡片
L553-592 · guessMime() ← 路由文件里的业务逻辑
interface 是每个 endpoint 各自,无 module 层聚焦
after · 五个按域拆的注册器
settings-routes.ts
config / models / credentials / skills toggle
project-routes.ts 已有 case/loose 兄弟
project CRUD
thread-routes.ts
thread CRUD + batch summary + meta 卡片
artifact-routes.ts
upload / plan / artifact / files-tree + guessMime
已存在 case-routes/loose-routes/quota-routes/public-routes/auth-routes/admin-routes 六份,风格已定
problem

项目里已有 6 个 *-routes.ts 分域文件,唯独 routes.ts 是一锅端。guessMime 40 行文件类型表塞在路由文件末尾,countTurns 派生的 batch-summary 计算也在路由里手滚 for 循环。路由层不该做业务派生。

solution

照现有 *-routes.ts 骨架剖 4 刀,guessMime 提到 shared 或独立 mime.ts。batch-summary 那段搬进 store/threads.tssummarizeThreads(dir)——规则回到规则该在的地方。

wins
  • locality:改文件预览只碰 artifact-routes
  • leverage:接口没变,callers 零改动
  • 删 4 个上帝方法的 co-location friction
  • 路由风格与已有 6 兄弟对齐
deletion test

拆完后每片能不能删?每片对应一片 UI 面板(设置页 / 项目 / 线程 / 产出),删任一片对应功能消失——不是 pass-through。四片都是真 seam。

3 · ThreadMirror 收拢 watermark 规则

Strong in-process
store/mirror.ts · store/threads.ts (L196-208, L217-249) · client-pool.ts (mirrorSession L349-367)
before · watermark 规则跨 3 文件
flowchart TD
  A["client-pool.mirrorSession()
协调"] -->|1. read meta| B["threads.getThread"] A -->|2. plan| C["mirror.planMirror(w, n)"] A -->|3. append tail| D["threads.appendMirrorBatch"] A -->|4. bump| E["threads.bumpSessionMirrored"] style A fill:#fef2f2,stroke:#dc2626 style B fill:#f8fafc style C fill:#f8fafc style D fill:#f8fafc style E fill:#f8fafc
"永不 truncate + watermark 单增" 的不变式靠调用方拼 4 步执行 —— 一步没做对 → messages.jsonl 重复 append
after · 一个 deep module 藏 4 步
flowchart TD
  A["client-pool.onTurnEnd(entry)"] -->|"mirror.absorb(entry.client)"| M["ThreadMirror module"]
  M -.->|内部| B["read watermark"]
  M -.->|内部| C["planMirror"]
  M -.->|内部| D["appendMirrorBatch"]
  M -.->|内部| E["bumpSessionMirrored"]
  style M fill:#0f172a,color:#f1f5f9,stroke:#0f172a
  style B fill:#e2e8f0,color:#64748b
  style C fill:#e2e8f0,color:#64748b
  style D fill:#e2e8f0,color:#64748b
  style E fill:#e2e8f0,color:#64748b
      
interface:mirror.absorb(client, projectDir, threadId);实现藏 4 步 + 不变式
problem

watermarkCONTEXT.md 命名的域概念)的"只增不减"规则理论上在 store/mirror.planMirror——但实际执行需要 caller 依序读 meta → plan → append → bump。任一步遗漏或顺序错都会破坏"永不 truncate"。这不是 mirror.ts 的 5 行数学,而是 client-pool.mirrorSession 里的 20 行编排。

solution

把 4 步收进 ThreadMirror.absorb(client, projectDir, threadId)——interface 就一个动词。store/mirror.ts 保留纯 planMirror 作为内部 seam(可单测),appendMirrorBatch / bumpSessionMirrored 变私有辅助或保留但降级为内部调用。

wins
  • locality:watermark 不变式集中一处
  • test surface = 一个 absorb 调用(不再 mock 4 步)
  • 删 client-pool 里 20 行编排 → deep 得多
deletion test

删掉 ThreadMirror?20 行编排在 client-pool 里复现,且不变式再度"靠约定"。seam 该有。

4 · extractText / messageText 三处重复 → 提到 shared

Strong quick win
event-translator.ts (L44-60) · store/threads.ts (L268-285) · agent-loop/summarizer.ts (L49-73)
before · 三份"Pi message → 文本"
event-translator extractText() store/threads messageText() summarizer messageText() 三份 15-20 行的 content-array-walk Pi 若改 content shape,改三处 (store/threads 版本处理 string content,另两处没有;已经在漂了)
after · shared/message-text
@yunzhan/shared extractText(msg) translator store/threads summarizer
problem

三处独立函数做同一件事:从 Pi AgentMessage.contenttype==='text' 抽字符串。store/threads.messageText 已经比 event-translator.extractText 多"string content 兜底"——三份实现已经在漂了。Pi 若改 content shape(thinking part、多模态),改三处。

solution

提到 @yunzhan/shared/message-text.ts——它反正只跟 Pi 的 AgentMessage shape 挂钩,跟三个 caller 的业务无关。三处 import。

wins
  • locality:Pi content 形状变化只改一处
  • leverage:3 callers, 1 impl
  • 纯函数,零风险
deletion test

删掉 shared/message-text?三处都会重新写一遍——就是现状。seam 该有。

5 · 模型 registry 单一源(消灭 KNOWN_MODELS 二重)

Strong quick win 已咬过一口
routes.ts (L100-106) · visibility.ts (L32)
before · 两份 known 列表
routes.ts L100-106
[
  { id: 'claude-opus-4-7',   label: 'Opus 4.7' },
  { id: 'claude-sonnet-4-5', label: 'Sonnet 4.5' },
  { id: 'claude-haiku-4-5',  label: 'Haiku 4.5' },
]
visibility.ts L32
const KNOWN_MODELS = [
  'claude-opus-4-7',
  'claude-sonnet-4-5',
  'claude-haiku-4-5',
] as const
上周 claude-opus-4.7 → -4-7 迁移那次两处忘同步的 就是这类 bug(见 commit 99b1be3 + visibility.ts L27-31 生产事故复盘)
after · registry 唯一
@yunzhan/shared/models.ts
export const MODELS = [
  { id, label, provider, tier },
  ...
] as const

export const MODEL_IDS = MODELS.map(m => m.id)
routes.ts / visibility.ts 都 import
problem

模型 id 是 前端 composer 下拉 / 后端 setModel / visibility 别名 map / 迁移表 四方约定的 wire。当前存在两个 known 列表,一处漂了另一处不知道;visibility.ts 里的 LEGACY_ID_MIGRATION 注释里明确坐实过一次生产事故(Opus 静默 fallback 到 Sonnet)。

solution

@yunzhan/sharedmodels.ts 一个 as const 表,两处 import。跟候选 1 (Provider Adapter) 天然合流:models 就是每个 adapter 的一个字段。

wins
  • locality:新增/改名一处改
  • 已咬过一口的 bug 类别消失
  • 与 candidate 1 天然合流
deletion test

删掉 shared/models?两处再写一遍——就是现状。seam 该有。

6 · store/threads.ts 拆规则 vs CRUD

Worth exploring 377 行混装
store/threads.ts (377 行 · 15 个导出)
before · 一坨 15 个导出,规则/CRUD/旁路混装
rule · countTurns · turn 域规则
rule · appendMirrorBatch · watermark 不变式
rule · recordActiveSession · session 换代规则
rule · bumpSessionMirrored
crud · listThreads / getThread / createThread
crud · patchThread / closeThread / reopenThread
crud · readMessages / appendMessage
旁路 · appendMonitorMessage · agent-loop 注入
旁路 · appendUserMessageMeta · v1.9 模型审计
旁路 · readMessagesTagged · 回贴 source 标签
after · 按角色三片
store/thread-rules.ts
countTurns · watermark · session 换代
纯函数或"读-改-写-in-context" 的规则;测试点
store/threads.ts (crud)
list · get · create · patch · close · reopen · readMessages · appendMessage
store/thread-sidecar.ts
agent-loop marks · user-message-meta · readMessagesTagged
problem

377 行里同时住着域规则countTurns 是 CONTEXT.md 命名的 turn;watermark 是 CONTEXT.md 命名的 watermark)、纯 CRUD、以及三个旁路(agent-loop marks / meta / tagged read)。想找"什么算一轮"要在 CRUD 里翻;三个旁路又跟 messages.jsonl 语义耦合但没被 mark 出来。

solution

按角色分三文件:thread-rules.ts(规则单点)· threads.ts(保留名字,只做 CRUD)· thread-sidecar.ts(旁路)。appendMirrorBatch 应属 rules。同时把候选 3 的 ThreadMirror 组合起来,rules 只留内部原语。

wins
  • locality:规则集中在 rules 文件
  • test surface:rules 是纯函数 easy 单测(现在 countTurns 没测)
  • 旁路的耦合性变可见
deletion test

rules 一片能删吗?删了 countTurns,两个 caller(routes meta / batch summary)复现相同 8 行 for 循环——过 deletion test。sidecar 相对边缘,可作 Worth exploring 而非 Strong。

7 · Skill enabled 状态:内存 vs 磁盘二源

Worth exploring invariant
skills.ts (L145 builtin hardcode · L61-83 persistence)
before · builtin 在读盘时被 override
// readSkillDir()
enabled: scope === 'builtin'
  ? true                    // hardcode
  : enabledMap[skillKey(...)] === true
若外部改 skills.json 手工 disable 一个 builtin skill,读回来仍然是 enabled: truesetSkillEnabled('builtin', ...) 会抛,但没有 checker 保证磁盘 state 与内存一致。
after · 一个"scope policy" 判定
flowchart LR
  D["skills.json
(disk)"] --> P{"scope policy"} B["builtin scope
rule: always on"] --> P P --> S["SkillInfo.enabled"] style B fill:#0f172a,color:#f1f5f9 style P fill:#e2e8f0
policy 是 module,interface 是 isEnabled(scope, name, disk);builtin 规则集中在此,读盘和写盘都过它。
problem

"builtin 永远开"这个规则出现在 L145(读时 hardcode)和 setSkillEnabled(写时抛)两处。规则一致但没有 policy 模块承载——文档描述在 L192-196 注释里,容易漂。

solution

skillEnablementPolicy 小 module(一个函数一个 error class),读/写两条路径都经它。是"internal seam",接口小 leverage 也小,但把 invariant 挂到一个可搜索名字上。

wins
  • locality:invariant 有名字,grep 得到
  • 可测:policy 是纯函数
  • 降"builtin 悄悄改 policy" 风险
deletion test

删掉 policy 模块?两处 hardcode 复现——但当前两处只有共 3 行代码,locality 未必比现在差多少。边界 candidate。若发现第二个 "永远开" 规则(比如某个 builtin skill 想强制勾选组合),policy 就赚回本。

8 · WS 事件订阅:从 sink-injection 改成 async iterator

Worth exploring 改动大
ws.ts (L144-167 subscribe) · client-pool.ts (setSink / sink field on entry)
before · WS 把 sink 塞给 pool,反向依赖
sequenceDiagram
  participant W as ws
  participant P as pool.entry
  W->>P: setSink(fn)
  Note over P: pool 内部触发时
调 entry.sink(raw) P-->>W: raw → translate → send Note over W: WS 不知道
sink 是否被调
after · WS 主动 for-await 事件流
sequenceDiagram
  participant W as ws
  participant P as pool.entry
  W->>P: events(key) : AsyncIterable
  loop for await raw of events
    P-->>W: raw
    W->>W: translate → send
  end
  Note over W: 一处循环
backpressure 显式
problem

WS 层是 CockpitEvent seamCONTEXT.md 命名的域概念)的实际下推口,但当前是 pool "回调进" WS——WS 不知道自己何时被喂事件,也没法 backpressure。断线时 setSink(undefined) 只切开 fn,pool 内的事件仍可能在飞。

solution

pool.setSink 换成 pool.events(key) 返回 AsyncIterable<RawPiEvent>。WS 一个 for await 循环全权控制翻译/派生/下推;断线 break

wins
  • WS 是 CockpitEvent seam 的唯一消费者,对齐 ADR-0001 精神
  • backpressure 显式化
  • Test:feed 事件 → WS 顺序断言,不再 mock sink 注入
deletion test

当前 sink-injection 已经算是 seam,问题是"反向"。改造收益是清晰化而非从零建 seam——所以 Worth exploring 而非 Strong。仅在下一个 event 消费方(比如后台 archiver)出现时才真正赚回 leverage。

9 · derive.ts 提成 tool→derivation 注册表

Worth exploring
derive.ts (122 行 · derivePlanUpdate + deriveArtifactDeclared)
before · 每个 derivation 一个函数, 主入口 if-if 串联
deriveBusinessEvents(raw, ctx):
  const plan = await derivePlanUpdate(raw, ctx)
  if (plan) out.push(plan)
  const art = await deriveArtifactDeclared(raw, ctx)
  if (art) out.push(art)
  // 加第三个 derivation → 改这里 + 加函数
after · 注册表按 toolName dispatch
const derivations = {
  [PLAN_TOOL_NAMES.taskCreate]:  planUpdateFromFile,
  [PLAN_TOOL_NAMES.taskUpdate]:  planUpdateFromFile,
  [PLAN_TOOL_NAMES.declareArtifact]: artifactFromIndex,
}

deriveBusinessEvents(raw, ctx):
  if (raw.type !== 'tool_execution_end') return []
  const fn = derivations[raw.toolName]
  return fn ? [await fn(raw, ctx)] : []
problem

deriveBusinessEvents 是"每个 tool → 派生哪个业务事件"的编排,但当前长成"手写两次 if(x)push"。Pi 迟早会加第三/第四个 customTool(比如 memo_write / skill_note),每次都得改 deriveBusinessEvents 加一段。

solution

toolName → derivation 的 map,加新 tool = 加一行注册。跟 event-translator 的 switch 呼应(那边处理 event.type,这边处理 tool.name),扩展的地方一目了然。

wins
  • 加 tool derivation = 加一行
  • test:derivation map 每 entry 单测
deletion test

目前只有 2 条 derivation。删掉 map,两个 if 复现——现状。只有出现第 3 条 derivation 时才真正赚。标 Worth exploring,等触发器。

10 · loose-threads.ts / credentials.ts :删除,把调用 inline

Speculative 删除 candidate
store/loose-threads.ts (73 行) · credentials.ts (46 行)
before · 两个 shallow wrapper
loose-threads.ts looseChatRoot() listLoose(home, u) → 转发 threads.list credentials.ts readCredentialStatus() setAnthropicApiKey() → 转发 Pi AuthStorage interface 面 ≈ 实现体积 store/threads (loose-threads 全转发过来) AuthStorage (credentials 全转发过来)
after · 调用直接 inline
store/threads list(dir), create(...) Pi AuthStorage (direct import) loose-routes dir = home+"/"+u+"/chat" routes /api/config 直用 AuthStorage
problem

loose-threads.ts:6 个函数全是 listLoose = (home, u) => listThreads(looseChatRoot(home, u))credentials.ts:2 个函数,全在 AuthStorage.create(...) 上加薄壳。interface 面≈实现面——典型 shallowCONTEXT.md 里 "Loose thread" 是域概念——但域概念的落点其实是 looseChatRoot 常量 + projectId=null 语义,不是这 6 个 wrapper。

solution

把两个文件删了:loose-routes.ts 直接算 dir = join(home, u, LOOSE_CHAT_DIR)store/threads/api/config 直接 import Pi.AuthStorageLOOSE_CHAT_DIR 常量提到 shared

wins
  • 119 行 shallow 代码消失
  • Pi AuthStorage 依赖显式(现在被 credentials.ts 藏了)
deletion test

删掉 loose-threads?6 行 join(home, u, 'chat') 在 loose-routes 的 5 个 handler 里复现——只在一个 caller 文件。删掉 credentials?2 行 AuthStorage.create(...).get(...)/api/config 一处复现。都不过 deletion test 的另一半——但删了没啥损失(因为原本就没藏东西)。

反问:这两个 wrapper 的价值可能不是 leverage 而是命名——让 loose-routes 里的"loose"意图明确、让 credentials 集中 Pi AuthStorage 耦合便于将来换。若这个考虑成立,就是 credentials.ts L15-16 的 R3 注释所说的"薄耦合点集中一处"——那就保留。需要在 grill 时定夺。

11 · skills.parseFrontmatter 换成 YAML 库

Speculative
skills.ts (L109-125 parseFrontmatter)
observation

当前用 20 行 regex 手抠 name: / description:。只覆盖单行标量,不支持多行 | / >,也不容错缩进。SKILL.md 是外部内容(用户自装 skill 也走),格式漂移风险不小。

why speculative

当前 88 个 skill 都是自家写的,格式稳定。库依赖是净新增。deletion test:删掉 parseFrontmatter → 20 行 regex 在一个 caller (readSkillDir) 内复现——就是现状。

trigger to reopen

出现第一个"用户自装 skill frontmatter 解析错"的 issue,或加第二个字段(tagsversion)时。

12 · summarizer.RECENT_N / SUMMARY_MODEL 抽到 config

Speculative
agent-loop/summarizer.ts (L29 SUMMARY_MODEL='claude-haiku-4-5', L31 RECENT_N=10)

硬编码 haiku + 最近 10 条。与 config.piModel 分家是故意的(监控用小模型,注释 L28 说明)。RECENT_N=10 是没证据的经验值。

why speculative

与 ADR-0003 同调:没证据别动。当前监控输出质量没投诉。抽到 config 是理论好,但引入新 config 表面积(会有第七个 env 变量)。

trigger to reopen

出现"监控总结不够长"/"选错模型"的具体投诉;或候选 1 落地后 provider adapter 里天然把 summarizeModel 表达进来——那时顺手就做。

top recommendation

候选 1 · Provider Adapter seam

唯一一个有真实痛点证据的候选——上周 MiniMax provider 加入失败被回滚。剩下的都是"发现了不干净"或"以后可能疼",这个是"已经疼过了"。 做完它顺手能吃到候选 4(extractText 提 shared)和候选 5(模型 registry 单一源), 因为 adapter 的字段自然把 models / defaultModel / summarizeModel 收拢在一处。

先做
  • 候选 4 · shared/message-text
  • 候选 5 · shared/models
两个都是零风险 quick win,先跑起来 warmup
主戏
  • 候选 1 · ProviderAdapter
  • anthropic 是第一 adapter
  • MiniMax 作为验证用例
跟进
  • 候选 3 · ThreadMirror
  • 候选 2 · routes 拆分
主戏落地后 seam 更清晰,再动大件