Skip to content

feat(providers): LLM seam 改造 Stack 合并版——统一流式入口/重试策略/拦截器注册化/轨迹账本 + 生产构建修复 - #605

Open
AlphaCatMeow wants to merge 13 commits into
Stack-Cairn:mainfrom
AlphaCatMeow:feat-llm-full-stack
Open

feat(providers): LLM seam 改造 Stack 合并版——统一流式入口/重试策略/拦截器注册化/轨迹账本 + 生产构建修复#605
AlphaCatMeow wants to merge 13 commits into
Stack-Cairn:mainfrom
AlphaCatMeow:feat-llm-full-stack

Conversation

@AlphaCatMeow

@AlphaCatMeow AlphaCatMeow commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Closes #591, Closes #593, Closes #596, Closes #598, Closes #600

概述

本 PR 是 LLM seam 改造 Stack 的合并版本,把原本 5 层 stacked PR(#590#594#597#599#601)整合为一个 PR 提交。原 5 层 PR 保持 Open 状态作为改动记录,本 PR 合并后会一并关闭。

Stack 各层职责(自底向上,每层在上一层的行为等价基线上推进):

  1. PR-0 golden 基线(原 test(providers): 建立五协议 wire payload 与传输装配 golden 快照基线 #590):五协议 wire payload 与传输装配的快照回归防线,零生产代码改动
  2. PR-1 seam 骨架(原 feat(providers): 引入 LLM seam 骨架——统一入口 llm.stream() 与协议适配器注册表 #594):引入统一入口 llm.stream() 与协议适配器注册表,streamByApi.ts 收缩为兼容壳
  3. PR-2 重试策略(原 feat(providers): 供应商级流式重试策略——default/off/custom 三态反转到设置层 #597):流内重试从全局常量反转为供应商级配置(default/off/custom 三态)
  4. PR-3 拦截器注册化(原 feat(providers): payload 拦截器注册化——finalize 管线反转到 seam 注册表 #599):payload 中间件组织权反转到 seam 注册表,支持具名拦截器动态插拔
  5. PR-4 轨迹账本(原 feat(trajectory): 重试/切换/传输事件写入轨迹账本——LLM 可观测性补齐 #601):重试/failover/传输三类运行时事实写入轨迹账本,落盘可回放,脱敏后不泄漏凭据

每层改动前均以上一层 golden 快照为基线验证行为严格等价,改动仅发生在各自声明的范围内。

评审后修订

按评审意见(见 PR 评论区),本分支已合并最新 main 并追加以下修订:

  1. 移除 style-to-js 越界 override1b959a58 合并 main + 3d6c2326):原"生产构建界面空白"修复中的 pnpm override(1.1.21 → 2.0.2)针对的是旧 vite 配置的激进代码分割;main 已在 fix(build): disable custom codeSplitting that crashed the release bundle (black screen) #613codeSplitting: false 根修同一问题,合并后 override 失去触发条件,且跨大版本覆盖超出消费方 hast-util-to-jsx-runtime 声明的 ^1.0.0 范围。package.json/pnpm-lock.yaml 已恢复与 main 完全一致。SkillCategoryControls 的惰性求值修复保留(无害的稳健性改进,不再作为黑屏根修复)。
  2. 拦截器名称唯一性5baa1196):installDefaultPayloadInterceptors 安装前同时校验已注册的自定义拦截器,与 usePayloadInterceptor 共用同一张名称注册表;全部校验先于任何状态写入,失败不留部分注册状态。附"自定义先注册、默认链后安装"加载顺序反转的回归用例。
  3. text-only 流内重试轨迹携带候选标签d88092d4):主候选与每个 fallback 在装配 options 时各自把标签绑进 streamRetry.onRetry(回调第 5 参),turn 层写入 noteRetryprovider 字段与 RetryAttemptRecord.providerLabel,与 agent 模式(target.label)同口径。failover 下备用供应商的流内重试自此可归属到具体候选。附双候选标签逐候选独立的回归用例。
  4. retry 落账 max 字段双重减一修正0438800c):withStreamRetry 回调第二参传入前已减去首次尝试(即重试预算,与状态提示 "(n/m)" 的 m 同口径),turn 层落账时误再减一导致账本值比实际预算小 1。该字段仅落盘、无 UI 渲染,属审计数据修正。

历史备注:生产构建安装包界面空白(已被 #613 取代)

Stack 完成后曾在本地生产构建(tauri build + NSIS 打包)验证中发现安装后界面空白,通过 WebView2 CDP 远程调试定位到两处旧分包配置(maxSize: 450_000)下的问题:style-to-js CJS/ESM interop 跨 chunk 失败、SkillCategoryControls 模块顶层 spread 早于目标 chunk 初始化。当时的修复与验证(含 EXCEPTIONS_COUNT: 0 诊断与截图)均在旧配置下完成;main 合入 #613 禁用自定义分包后该问题已根修,本 PR 相应移除了 override(见上节),仅保留惰性求值。

验证结果

继承自各层 PR 的自动化验证(详见各原 PR 描述,此处不重复罗列用例明细):

  • PR-0 golden 两套件 13/13
  • PR-1 seam 套件 8/8
  • PR-2 retry-policy 套件 13/13
  • PR-3 interceptors 套件 8/8(含与旧数组组合的逐字段行为等价断言)
  • PR-4 scrub-transport 套件 14/14 + recorder/event-log/stream-retry 增补 12 用例
  • 全链路累计 127/127 回归通过(golden + seam + retry-policy + interceptors + failover/text-only-failover)

评审后修订验证(合并 main 后的最新分支头):

  • agent-gui tsc ✅;前端套件 2604/2604 ✅(含评审修订新增的回归用例)
  • gateway web 631/631 ✅
  • pnpm --dir crates/agent-gui build 生产构建 ✅(fix(build): disable custom codeSplitting that crashed the release bundle (black screen) #613 codeSplitting: false 下单 chunk 产物,与 main 行为一致)
  • CI 全绿(GUI / Gateway / Gateway WebUI / Docker Smoke / Diff Hygiene / PR compliance);diff 相对 main 不含构建配置与依赖变更

人工验收结果

继承自各层 PR 在 Windows Tauri 调试客户端的人工验收(详见各原 PR):

  • 带工具多轮对话、会话标题生成、手动压缩、断网重试提示、failover 切换 ✅
  • 流式重试三态控件行为与默认态兼容性 ✅
  • 附件发送、联网搜索、thinking 档位、拦截器链尾观测不变量 ✅
  • agent/text 两种模式的 failover 落账、传输快照逐候选独立性、重启持久化回放、脱敏(错误文本无真实 key 片段)✅

Screenshots / preview

LLM seam 骨架(原 PR-1,#594):

seam-core

Golden 基线(原 PR-0,#590):

golden-baseline

流式重试三态控件(原 PR-2,#597):

retry-policy-1 retry-policy-2 retry-policy-3

拦截器注册化(原 PR-3,#599):

interceptors-1 interceptors-2

轨迹账本(原 PR-4,#601):

trajectory-failover

trajectory-retries

旧配置下生产安装包修复后界面(历史记录,见"历史备注"一节):

production-build-fixed

关联 PR

原 stacked PR:#590#594#597#599#601(本 PR 合入后关闭,改动历史保留在各自分支)

为 LLM seam 改造提供行为等价判定基准:
- wire-payload-golden:anthropic-messages / openai-completions /
  openai-responses / google-generative-ai / deepseek-responses 五协议
  固定输入下的完整请求体逐字段锁定(thinking 档位、工具、缓存断点、
  text-only 双形态),走真实 pi-ai stream() 与全部 payload 中间件,
  onPayload 链尾截获后中断,零网络。
- transport-golden:prepareProviderRequest 完整输出快照(反代 URL、
  全量头集、base64 覆盖包解码断言、鉴权头排除、full URL 模式、
  useSystemProxy 开关),并锁定 failover 逐候选传输配置独立性
  (主选走代理+备选直连互不泄漏,双向拓扑)。

零生产代码改动。
将 streamByApi.ts 的五协议 switch 原样搬移为 service/ 下的双适配器
(piAiAdapter 承接 4 条 pi-ai 协议,deepSeekAdapter 承接 deepseek 原生),
经 api→adapter 注册表分发;新增统一流式入口 llm.stream(),携带仅 dev
构建生效的请求信封冻结与一次性分发不变量。streamByApi.ts 收缩为保留
原签名的兼容壳(分发针孔),agentRunner 与 textOnlyRuntime 共 5 处调用
换用统一入口。行为严格等价:PR-0 两个 golden 套件(13 用例)零修改
通过;新增 seam 单测 8 用例覆盖注册表分发、错误文案逐字等价、dev
冻结开关与双入口 wire payload 等价。零 Rust 改动、零镜像文件改动。
将流内重试策略的归属权从全局常量反转到供应商配置(PR-2,stack 第 3 层):

- 共享真源 agent-ui settings 新增 CustomProvider.retryPolicy?(off |
  custom+maxRetries),maxRetries 为首次失败后的重试次数(不含首次请求,
  钳位 1..10)。normalizeProviderRetryPolicy 保证非法/缺省一律落 default
  且不落字段,旧配置零迁移;default 态在持久层不存在。
- agent-gui 运行时经 ProviderRuntimeConfig 唯一构造点透传策略,新增
  resolveStreamRetryConfig 把策略解析为 withStreamRetry 选项(off →
  disabled,custom → maxAttempts=maxRetries+1,缺省 → 空对象落全局默认)。
  agentRunner 与 textOnlyRuntime 两个 streamRetry 注入点展开合并,回调
  语义与 buffer-until-commit 不变;failover 逐候选使用各自 runtime 的
  策略。streamRetry.ts 与协议适配器零改动,
  DEFAULT_STREAM_RETRY_MAX_ATTEMPTS 降级为未配置时的默认值。
- 共享 settings UI(ProviderModal/ProviderModalView)请求面板新增
  流式重试三态控件(默认/关闭/自定义次数),GUI 与 WebUI 镜像同源;
  UI 展示镜像常量 PROVIDER_RETRY_DEFAULT_MAX_RETRIES 与运行时真源的
  一致性由单测锁定。
- 新增 provider-retry-policy.test.mjs(13 用例):归一化矩阵、构造点
  透传、消费方合并语义三种 mode、failover 候选策略独立、口径换算。
  PR-0 golden 两套件与既有 stream-retry 套件零修改通过。
把 payload 中间件的组织权反转到 LlmService seam(PR-3,stack 第 4 层)。
未注册任何自定义拦截器时行为与 PR-2 严格等价:

- 新增 service/interceptors.ts 注册表:具名 PayloadInterceptor
  (name + intercept),usePayloadInterceptor / llm.use() 返回幂等
  dispose,同名重复注册抛错;组合链带失效缓存,注册/移除时重建,
  finalize 热路径(agentRunner 每轮、textOnly 每次调用)零重组开销。
- payloadPipeline.ts 的 10 个中间件原样包装为具名默认拦截器,模块
  初始化时一次性安装,顺序与注册化前数组逐项一致(顺序即协议正确性
  的一部分,由顺序快照测试锁定)。payload-debug-logging 钉住链尾:
  自定义拦截器插入默认之后、链尾之前,自定义改动仍被调试日志观测。
- finalizeProviderStreamOptions 改为从注册表组合执行;agentRunner
  与 textOnlyRuntime 两处调用零改动,composePayloadMiddlewares 等
  既有导出保留,10 个中间件实现文件零改动。
- 新增 llm-interceptors.test.mjs(8 用例):默认顺序快照(10 个
  名字)、llm.use 同源、params 可见与 options 变换、插入位置、链尾
  观测不变量、dispose 幂等、同名抛错、与旧数组组合逐字段等价(多
  形态参数矩阵)。golden 两套件、seam、retry-policy、stream-retry
  及中间件相关回归零修改通过。
seam 改造第五层(PR-4):流内重试与跨供应商 failover 此前只喂 UI 临时状态,
审计线索随进程消失。本层把三类事实落入轨迹账本,重启后仍可回放:

- 线格式新增 failover(from/to/ti/err)与 transport(p/o/sp/fu/hn)事件;
  retry 增补 p(候选标签)与真实退避时长——failover 下各候选的重试可区分
- transport 快照只采头名与路由标记,逐候选独立记录,审计"主选带
  use-system-proxy、备选不带"的传输装配独立性;头值一律不采集
- recorder 全部 err 出口接入密钥洗涤(URL query key/Bearer/已知 key 形状),
  供应商报错回显的凭据不落盘;测试含反向断言
- withStreamRetry 退避提前计算并经 onRetry 上报整毫秒值——上报的就是实际
  要睡的值;取整同时关闭浮点往返身份漂移(serde_json ULP 误差会让同一条
  重试在收敛账本里出现两份)
- agent 模式 failover 首次落账(此前仅 onToolStatus);text 模式 failover
  从误记 noteRetry 改为独立 failover 事件,fromLabel/toLabel/targetIndex 不再丢失
- OptionsTab/OverviewTab 渲染三类新行,中英文案;旧读端对未知事件种类
  按既有收敛路径静默忽略,线格式只增不改
1. style-to-js 是纯 CJS 包(无 exports 字段),与 rolldown 生产构建的
   interop 判断不兼容,通过 pnpm.overrides 固定到 2.0.2 规避。

2. SkillCategoryControls.tsx 中 STORE_CATEGORY_OPTIONS 在模块顶层对
   跨 chunk 导入的 CLAWHUB_CATEGORY_SLUGS 做 spread 展开,在激进代码
   分割(vite.config.ts maxSize: 450_000)下偶发早于目标 chunk 初始化
   完成执行,读到 undefined 抛出 TypeError。改为惰性求值+memoization
   规避。

两处均只在生产打包后复现,dev 模式因不做代码分割而无法触发,通过
WebView2 CDP 远程调试(--remote-debugging-port)连接安装后的实例,
逐步定位 console 异常与 DOM 渲染状态确认根因。修复后重新构建安装包,
CDP 诊断 EXCEPTIONS_COUNT: 0,截图确认界面完整渲染。
@su-fen

su-fen commented Aug 25, 2026

Copy link
Copy Markdown
Member

审核意见(只列最主要的一点):

本 PR 追加的"生产构建修复"与 main 上已合入的 #613 修的是同一个黑屏 bug,方案互相冲突,其中 style-to-js 的 pnpm override 是越界且已无必要的风险,建议合并前处理。

  • PR 基于的 main 落后了几个提交。追加修复针对的是旧 vite 配置的激进代码分割(maxSize: 450_000),而 main 的 fix(build): disable custom codeSplitting that crashed the release bundle (black screen) #613 已经用 codeSplitting: false 从根上修掉了同一个问题(commit 说明同样点名了 style-to-js 的 CJS interop 链)。合并到当前 main 后代码不再分包,本 PR 两处修复都失去了触发条件。
  • 其中 pnpm.overridesstyle-to-js 从 1.1.21 强制升到 2.0.2 是跨大版本的越界覆盖——实际消费方 hast-util-to-jsx-runtime 声明的范围是 ^1.0.0。这个 override 会永久生效,而合并后已没有存在的理由,属于无必要的兼容性风险。SkillCategoryControls 的惰性求值无害,可以保留。
  • PR 描述里的 tauri build + NSIS + CDP 验证是在旧配置(有代码分割)下做的,合并后产物形态完全不同,该验证结论不再适用。

建议:rebase 到最新 main,移除 style-to-js override(或在 PR 里明确论证保留理由),然后重做一次安装包验证(尤其确认 markdown 内联样式渲染——style-to-js 2.x 的实际消费路径——正常)。


其余部分没有发现需要改代码的问题。已独立验证:五协议 switch 与 payloadPipeline 10 个中间件为逐行/逐项等价搬移;线格式"只增不改"对旧读端安全;failover 落账的 targetIndex 映射与逐候选重试策略正确。并用 git merge-tree 模拟合入当前 main:无冲突,在合并后的树上 tsc 通过、agent-gui 前端 2576/2576、gateway web 631/631 全部通过。

@su-fen

su-fen commented Aug 25, 2026

Copy link
Copy Markdown
Member

审核意见:前三项问题

1. [需要合并前处理] 生产构建修复已被当前 main#613 取代

PR 基于 83bee936,但当前 main 已合入 313dd976#613),通过将 crates/agent-gui/vite.config.tsrolldownOptions.output.codeSplitting 设为 false,从根上避免 style-to-js → style-to-object → inline-style-parser 的 CJS 链跨 chunk 初始化失败。

因此本 PR 在 package.json 中将 style-to-js1.1.21 强制覆盖到 2.0.2 已不再必要,而且 hast-util-to-jsx-runtime 声明的依赖范围是 ^1.0.0,跨大版本 override 会引入额外兼容性风险。

建议先 rebase 到最新 main,移除 style-to-js override 及对应 lockfile 变更,再重新执行 release/安装包验证。SkillCategoryControls 的惰性求值修复可以单独保留,但不应被当作当前黑屏问题的根修复。

2. [P2] 默认拦截器安装与自定义拦截器注册之间存在名称唯一性漏洞

usePayloadInterceptor()crates/agent-gui/src/lib/providers/service/interceptors.ts:69-:80 会检查重复名称,但 installDefaultPayloadInterceptors():46-:60 只检查默认列表内部的重复,不检查已经注册的自定义拦截器。

如果插件先注册名为 anthropic-automatic-caching 的自定义拦截器,再加载 payloadPipeline.ts,最终链中会出现两个同名拦截器,而不是抛出重复注册错误。这在正常入口下不一定触发,但 HMR、插件初始化和测试加载顺序都可能遇到。

建议让默认拦截器和自定义拦截器共用名称注册表,并在安装默认链前做完整校验,确保失败时也不会留下部分注册状态。

3. [P2] text-only failover 的 retry 轨迹缺少供应商标签

Agent 模式会在 crates/agent-gui/src/lib/chat/runner/agentRunner.ts:1350 附带当前候选的 providerLabel,但 text-only 路径在 crates/agent-gui/src/pages/chat/turns/runTextConversationTurn.ts:454-:460 记录 retry 时没有写入供应商信息。

crates/agent-gui/src/lib/providers/runtime/textOnlyRuntime.ts:124-:127 只把通用 onRetry 回调透传给当前目标,因此当备用供应商发生多次流内重试时,轨迹只能看到次数、错误和延迟,无法直接区分是哪一个候选在重试。

这不影响请求本身,但会削弱 failover 诊断和轨迹审计能力。建议让 text-only 的 retry 回调携带当前 target label,并在 recorder 事件中保持与 agent 模式一致。

su-fen and others added 4 commits August 26, 2026 02:13
跨大版本 override(1.1.21→2.0.2,消费方 hast-util-to-jsx-runtime 声明 ^1.0.0)
的触发条件是旧 vite 配置的激进代码分割;合并 Stack-Cairn#613 后产物不再分包,
override 失去存在理由,保留只剩兼容性风险。SkillCategoryControls 的
惰性求值修复无害,保留。

Co-authored-by: Cursor <cursoragent@cursor.com>
installDefaultPayloadInterceptors 原只查默认列表内部重复;若加载顺序上
自定义拦截器先注册(llmService 不传递性求值 payloadPipeline,插件初始化/
HMR/测试加载器均可达),同名默认拦截器会静默进链而非抛错。现与
usePayloadInterceptor 共用同一张名称注册表,全部校验先于状态写入,
失败不留部分注册状态。

Co-authored-by: Cursor <cursoragent@cursor.com>
buildTextOnlyStreamOptions 为每个候选(主/各 fallback)独立绑定
providerLabel 进 streamRetry.onRetry,turn 层把标签写入 noteRetry 的
provider 字段与 RetryAttemptRecord.providerLabel,与 agent 模式
(agentRunner target.label)同口径。failover 下备用供应商的多次流内
重试自此可归属到具体候选,补齐轨迹审计能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
@su-fen

su-fen commented Aug 25, 2026

Copy link
Copy Markdown
Member

已按三条审核意见处理,推送至本分支(eca93ab1..d88092d4,共 4 个提交):

1. 生产构建修复与 #613 冲突 → 已处理(1b959a5 + 3d6c232)

  • 合并最新 main(含 fix(build): disable custom codeSplitting that crashed the release bundle (black screen) #613codeSplitting: false),无冲突
  • 移除根 package.jsonstyle-to-js: 2.0.2 的 pnpm override 及对应 lockfile 变更,两文件恢复至与 main 完全一致(消费方 hast-util-to-jsx-runtime 声明范围 ^1.0.0,回到正常解析的 1.1.21)
  • SkillCategoryControls 的惰性求值修复按意见保留(无害),不再作为黑屏问题的根修复

2. 拦截器名称唯一性漏洞 → 已修复(5baa119)

installDefaultPayloadInterceptors 安装前同时校验已注册的自定义拦截器名称,与 usePayloadInterceptor 共用同一张名称注册表;全部校验先于任何状态写入,失败不留部分注册状态。新增回归用例:独立模块图下先 llm.use({ name: "anthropic-automatic-caching" }) 再加载 payloadPipeline.ts,断言默认链安装抛错、链上只剩先注册的自定义项。

3. text-only retry 轨迹缺供应商标签 → 已修复(d88092d)

  • buildTextOnlyStreamOptions 新增 providerLabel 参数,主候选与每个 fallback 在装配 options 时各自把标签绑进 streamRetry.onRetry(作为回调第 5 参),不再共享无标签的通用回调
  • turn 层把标签写入 trajectory.noteRetryprovider 字段与 RetryAttemptRecord.providerLabel,与 agent 模式(target.label)同口径
  • 新增回归用例:failover 双候选下分别触发装配好的 onRetry,断言标签逐候选独立

验证

  • agent-gui tsc ✅;前端套件 2604/2604 ✅(含新增 2 用例)
  • gateway web 631/631 ✅
  • pnpm --dir crates/agent-gui build 生产构建 ✅(codeSplitting: false 下单 chunk 产物,与 main 行为一致)

备注:按第 1 条意见,合并前建议在 Windows 上重走一次 tauri build + NSIS 安装包验证(尤其 markdown 内联样式渲染——style-to-js 回到 1.x 后的实际消费路径);本次修复环境为 macOS,未执行该项。

withStreamRetry 回调的第二参传入前已减去首次尝试(即重试预算,
与状态提示 "(n/m)" 的 m 同口径;stream-retry 测试断言 maxAttempts:5
时回调收到 4),turn 层落账时误以为"含首次尝试"又减一次,导致账本
max 比实际预算小 1(默认策略记 4,实际 5)。该字段仅落盘、无 UI
渲染,纯审计数据修正。

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment