Files
Jyotisha/docs/tasks/TASK-consult-three-channels-20260918.md
T

11 KiB
Raw Blame History

TASK · 咨询运行时重做:进度 / 思考 / 正文三通道在生成时分离(2026-09-18)

落地分支:codex/consult-three-channels-20260918,基线 origin/staging @ 742ffbc6。 设计稿把三则产品 bug 写成 BUG-939/940/941,与 staging 上门禁合同编号冲突,实现改号 BUG-942(删词)/ BUG-943(正文记账)/ BUG-944110s 闸刀)

分支:docs/three-channel-redesign-20260918。基线 origin/staging = 5a1dcbd2。 目标:干净的思考内容可展示、进度可见、正文只剩结论,三者互不混流,且回答质量不下降。 代价不设限(可以多跑模型轮次、可以落库、可以把运行搬出 HTTP 请求),但不得靠事后正则修补。

0. 病根一句话

产品把一个文本通道当三个用,然后在下游用正则把它们劈开。凡是「先混流、再过滤」的设计,过滤器迟早在错误的时刻删掉正确的东西——这正是现在同时产生「脏思维链」「正文里有思考」「空回答」的同一个根。

实证(origin/staging 行号,会漂):

  • frontend/src/lib/public-thinking.ts:27-33:一句话通过 CJK 门之后,还要 .replace(/[A-Za-z]{4,}/g, "") 删掉所有 ≥4 字母的英文词。模型原句 The user asked "这周哪些事最好先放一放" — are put off. 先被 buffer.slice(cjk) 从第一个汉字截断(留下孤儿引号和破折号),再被删词(asked/user 没了,are/put/the 三字母以内全留),输出 "这周哪些事最好先放一放" — are put .不是模型乱,是过滤器把信息量最大的词删了、把连词留下了。
  • frontend/src/lib/consultation-thinking-plan.ts:12-16:本命正文章节 = 统一参数与原始结构 + 各领域 + 技法审计表 + 现代生活。正文第一节就是岁差/上升/宫位罗列,倒数第二节是技法审计表——方法学记账被当成用户读物。同文件 DAILY_HEADING 已经是干净口径,本命路线没跟上。
  • frontend/src/app/api/consult/route.ts:898:整轮只有一个 AbortSignal.timeout(110_000),被首轮、工具、每一节分段写作、续写、重试共用。2026-09-18 的 run 1b81e263:工具 61.7s(2 领域 ≈31s/个,而预算常数 consultation-tools.ts:93 写的是 21s),随后 5 节写作全部无 thinking、无 text、无 error chunkstream-agent-response.ts:408 会把 error 记成 model-stream-error,回执里一条都没有)——沉默流 = 被掐断的流,于是 5 次 section-empty-retry + 1 次 answer-retryempty_answer,用户等 110 秒拿到一句「本次没有生成回答」。

1. 四条设计原则

  • P1 三通道在生成时就分开。 进度是服务端确定性事件;思考是模型有意写给用户的结构化条目;正文只有结论散文。任何通道都不靠下游切分得到。
  • P2 provider 的 reasoning token 永不外发。 thinking: "enabled" 保留(它提升质量),但 reasoning-delta 只进服务端日志(截断 + 有保留期),不进事件流、不进数据库可见字段、不进 UI。展示给用户的「思考」是产品产物,不是 CoT 抓取。
  • P3 正则只能当门,不能当刀。 用正则判断「接受 / 拒绝 / 重来」可以;用正则就地删词、挖空、替换半句一律删除。这条同时判掉思维链的 [A-Za-z]{4,} 删词和正文的 [具体时间已省略] 就地替换。
  • P4 时钟是预算,不是闸刀。 每个阶段开工前向账本要时间;要不到就降级(少领域、少条目、单段成文),不允许静默 abort。任何 abort 必须在回执里留痕。

2. 新运行时:四个 pass + 一个预算账本

Pass 0  服务端确定性     绑方法、定路由、定主题(入口钉死)          无模型
Pass 1  读题与取证计划   结构化 JSONreading + steps[] + domains[]  模型
        → 计算开始前就把「思考」面板填满,用户在 60s 计算期有东西看
计算    逐领域执行       确定性进度事件 progress{current,total}       无模型
        → Pass 1 的 step 状态 pending → running → done
Pass 2  证据解读         每个 step 产出 {stepId, finding, refs, confidence}  模型
        → 这就是用户看到的「思考内容」:中文、成句、可校验;同时是写作输入
Pass 3  成文             只写结论散文,输入 = 问题 + 证据契约 + findings      模型
Pass 4  合规校验         检测越权断言;不通过则指名违规句退回 Pass 3 重写      模型/确定性

要点:

  1. 思考通道不再是碎片流。 think.step 一次发一条完整条目,不发 chunk。没有 chunk 就没有「按 chunk 过滤」这回事,public-thinking.ts 整个消失。要打字机效果,前端对完整条目做动画,不要靠网络分片。
  2. 正文一次成文。 取消 composeSection 的逐节往返(route.ts:1185-1203):大输出预算 + 只有 finishReason=length 才续写。进度粒度由 think 通道提供,不再需要用分段来制造「正在写 X」。
  3. Pass 2 是 grounding 步骤,不是装饰。 强制模型先把证据显式转成带出处的结论候选,再写散文——这通常提升质量(少空话、少幻觉),也让「思考内容」天然可读。
  4. 诚实性红线: Pass 2 是模型为用户重述的推理依据,不等于 provider 内部 CoT。UI 文案只能叫「推理过程 / 判断依据」,不得宣称是模型的真实思考。

事件协议 v2

run.started      {runId, requestId}
phase.started    {phase, label, progress?: {current, total}}   // 确定性,永不含模型文本
phase.completed  {phase, durationMs}
think.plan       {steps: [{id, title}]}                        // Pass 1
think.step       {id, status: "running"|"done", text?}         // Pass 2,一条完整条目
answer.delta     {text}                                        // Pass 3,只有散文
run.completed    {receipt} | run.failed {code, receipt}
  • thinking.delta 从协议里删除thinking.sectionthink.plan + think.step 取代。
  • 每条 think.step.text 过 zod(长度、必须成句、必须含 CJK);校验不过就丢掉这一条并按计划标签兜底,绝不改写这条的字。这是 P3 的落点。
  • 客户端三个区域各认各的事件:进度条认 phase.*,思考面板认 think.*,正文认 answer.*。现有 ConsultationRunTimeline 的 method/calculate/think/write 四类行可以直接接 v2,不用重画。

3. 删除清单(不是改造,是删掉)

  1. frontend/src/lib/public-thinking.ts —— 整个文件。
  2. frontend/src/lib/stream-agent-response.ts 的两处 reasoning-delta → thinking.delta 映射(约 277、431 行)。reasoning 改为只写服务端日志。
  3. frontend/src/lib/consultation-agent-events.tsthinkingDeltaSchemathinkingSectionEventSchemav2 用新事件)。
  4. frontend/src/lib/consultation-run-timeline.tsthinking.delta 的兜底分支(约 107 行)。
  5. frontend/src/lib/timing-output-guard.ts就地替换exactTimingPatterns[具体时间已省略]guaranteeConclusionPatterns[保证性结论已省略]guardGeneralNoBirthTimeOutput 的整句替换。正则本身保留为检测器供 Pass 4 判定,但不得再写回文本。
  6. route.tscomposeSection / stream-agent-response.tscomposeByHeadings + section-empty-retry(一次成文后它们没有存在理由)。

4. 正文长什么样

  • 章节改成面向问题的口径(照 DAILY_HEADING 的形状重做本命计划),例如:先回答你的问题 / 盘里支持这个判断的地方 / 时间怎么看 / 这周可以做的一件事
  • 统一参数与原始结构技法审计表 退出正文,改由证据面板渲染——数据本来就是结构化的(workflowReceipttechniqueAuditTable),不需要模型复述成散文。面板默认折叠。
  • 正文里出现 ##统一参数、技法审计行、领域 id、英文术语堆砌,一律算 Pass 4 不通过。

5. 时钟与预算(治 empty_answer,顺带让「慢」变成可见进度)

  • 一个 run 一本预算账本{总预算, 已用, 每阶段预留}。每个阶段开工前申请,不足就降级而不是硬跑。
  • 每个 stream 自己的 AbortSignal = min(阶段预算, 剩余总预算)任何 abort 追加一条 kind:"abort" 运行步,回执必须能区分「模型没写」和「流被掐」——现在两者长得一模一样,这是这次查不出来的直接原因。
  • 领域时长常数按实测重算(当前实测 ≈31s/领域,常数写的 21s),上限随之下调;CONSULTATION_ANSWER_RESERVE_MS 是给「一次成文」设计的,一次成文回归后它才重新成立。
  • 彻底版(建议本单一起做):把 run 从 HTTP 请求里搬出来——后台 job + 事件日志表 + 客户端按 runId 断线重放。函数 120s 上限不再是产品上限;同时 evidence packet 随 job 落库,追问直接复用,不必重跑 60s 计算(这也顺手修掉声明窗口路线「existing packet 根本不存在」的那条自相矛盾)。

6. 怎么证明「质量没下降」

改前改后各跑同一组 20 条真实问题(覆盖本命 / 声明窗口 / 无分钟 / 今日入口),记录:

口径 方法 要求
结论具体性、证据引用、可读性 人工双盲打分 不低于基线
越权断言条数 Pass 4 检测器统计 ≤ 基线,且改写次数为 0(现在是每次都在改写)
正文里方法学记账段落数 关键词扫描 0
思考面板可读性 人工 每条成句、中文、无孤儿标点
首字延迟 / 总时长 / 失败率 埋点 失败率下降;总时长不劣于基线

7. 硬红线

  1. 不得再出现「先混流再过滤」的新代码。新增正则必须能回答「它是门还是刀」,是刀就不许进。
  2. tsc --noEmit 0 错、npm run lint 0 error;测试总数不得低于基线。删掉的 public-thinking 相关断言必须被 v2 的「校验不过整条丢弃」断言替代,不能净减。
  3. provider reasoning 不得出现在任何对外事件、数据库可见字段或日志的完整原文里(日志只留截断 + 保留期)。
  4. 事件协议 v2 与 v1 并行一个发布周期,客户端两者都能渲染;回执 schema 仍是 strict,新增字段走白名单。
  5. 任何一次 run 只要已经产出过正文片段,就不得以 empty_answer 结束。