فهرست منبع

feat(context-hub): 稳定缓存前缀并追踪供应商请求

拆分固定系统规则与动态任务规则,避免章节号、Skill 和任务类型污染缓存断点前内容。

基于最终请求结构生成前缀指纹,并贯通 Agent、AI 大纲和正文工作流的逐请求缓存、耗时及 TTFT 诊断。

区分 Codex 最近一次调用与线程累计 Token;内部请求数不可观测时不再使用外层 Agent 轮次代替。
darknessomi 3 هفته پیش
والد
کامیت
c4512e97e4
30فایلهای تغییر یافته به همراه1364 افزوده شده و 60 حذف شده
  1. 21 6
      src/components/chat/chat-panel.tsx
  2. 122 1
      src/components/chat/context-trace-panel.spec.tsx
  3. 95 3
      src/components/common/context-hub-stats-summary.tsx
  4. 40 6
      src/components/sources/outline-chat-panel.tsx
  5. 2 0
      src/lib/agent/codex-app-server-runner.spec.ts
  6. 7 1
      src/lib/agent/codex-app-server-runner.ts
  7. 4 0
      src/lib/agent/pipeline.ts
  8. 4 0
      src/lib/agent/plugins/build-system-prompt-plugin.spec.ts
  9. 14 1
      src/lib/agent/plugins/build-system-prompt-plugin.ts
  10. 11 0
      src/lib/agent/runner.spec.ts
  11. 11 1
      src/lib/agent/runner.ts
  12. 1 0
      src/lib/agent/tool-executor.ts
  13. 38 0
      src/lib/agent/tools/run-chapter-workflow.spec.ts
  14. 1 0
      src/lib/agent/tools/run-chapter-workflow.ts
  15. 9 0
      src/lib/agent/types.ts
  16. 97 0
      src/lib/context-hub/normalize-stats.spec.ts
  17. 61 0
      src/lib/context-hub/provider-usage.spec.ts
  18. 35 4
      src/lib/context-hub/provider-usage.ts
  19. 60 2
      src/lib/context-hub/types.ts
  20. 152 6
      src/lib/llm-client.ts
  21. 72 2
      src/lib/llm-client.usage.spec.ts
  22. 131 0
      src/lib/llm-request-trace.spec.ts
  23. 248 0
      src/lib/llm-request-trace.ts
  24. 3 1
      src/lib/novel/chapter-execution-contract.ts
  25. 3 1
      src/lib/novel/chapter-execution-report.ts
  26. 5 1
      src/lib/novel/chapter-plan-compliance.ts
  27. 41 0
      src/lib/novel/deep-chapter-generation.spec.ts
  28. 72 24
      src/lib/novel/deep-chapter-generation.ts
  29. 2 0
      src/lib/novel/review-adapter.ts
  30. 2 0
      src/lib/novel/writing-entity-web-search.ts

+ 21 - 6
src/components/chat/chat-panel.tsx

@@ -1754,6 +1754,10 @@ export function ChatPanel() {
 
       const prePluginSystemPrompt = prePluginResult?.finalSystemPrompt?.trim()
       const prePluginSystemRulesPrompt = prePluginResult?.finalSystemRulesPrompt?.trim()
+      const stableSystemRulesPrompt = prePluginResult?.stableSystemRulesPrompt?.trim()
+      const dynamicSystemRulesPrompt = prePluginResult?.dynamicSystemRulesPrompt?.trim()
+      const hasSplitSystemRules = prePluginResult?.stableSystemRulesPrompt !== undefined
+        || prePluginResult?.dynamicSystemRulesPrompt !== undefined
       const baseSystemPrompt = [
         prePluginSystemPrompt || sessionAgentSystemPrompt,
         qmQuaiSystemPrompt ? `## QM-QUAI 技能\n${qmQuaiSystemPrompt}` : "",
@@ -1773,14 +1777,17 @@ export function ChatPanel() {
         : [
             baseSystemPrompt,
           ].filter(Boolean).join("\n")
-      const contextHubSoftwareRules = prePluginSystemRulesPrompt || sessionAgentSystemPrompt
+      const contextHubSoftwareRules = hasSplitSystemRules
+        ? (stableSystemRulesPrompt ?? "")
+        : (prePluginSystemRulesPrompt || sessionAgentSystemPrompt)
       const contextHubSystemContent = contextHubResult
         ? buildContextHubSystemContent(contextHubSoftwareRules, contextHubResult, [
+            dynamicSystemRulesPrompt ?? "",
             qmQuaiSystemPrompt ? `## QM-QUAI 技能\n${qmQuaiSystemPrompt}` : "",
-            prePluginSystemRulesPrompt ? "" : taskDirective,
+            prePluginSystemRulesPrompt || hasSplitSystemRules ? "" : taskDirective,
             goldenDirective,
-            prePluginSystemRulesPrompt ? "" : selectedSkillsPrompt,
-            !prePluginSystemRulesPrompt && prePluginResult?.selectedSkills?.length
+            prePluginSystemRulesPrompt || hasSplitSystemRules ? "" : selectedSkillsPrompt,
+            !prePluginSystemRulesPrompt && !hasSplitSystemRules && prePluginResult?.selectedSkills?.length
               ? `## 当前会话写作技能\n${buildSelectedSkillsPrompt(prePluginResult.selectedSkills)}`
               : "",
           ])
@@ -1873,7 +1880,9 @@ export function ChatPanel() {
           : sessionTools
         const usageSnapshotBase = buildContextUsageSnapshot({
           windowTokens: getEffectiveMaxContextSize(agentConfig.llmConfig),
-          softwareRules: contextHubResult ? contextHubSoftwareRules : systemPromptForConfig,
+          softwareRules: contextHubResult
+            ? [contextHubSoftwareRules, dynamicSystemRulesPrompt].filter(Boolean).join("\n\n")
+            : systemPromptForConfig,
           toolDefinitionsJson: JSON.stringify(toOpenAITools(advertisedTools)),
           stableTokens: contextHubResult?.stats.stableTokens,
           summaryTokens: contextHubResult?.stats.summaryTokens,
@@ -1973,7 +1982,7 @@ export function ChatPanel() {
             record.lastRequestUsage ?? record.usage,
           ),
         )
-        if (contextHubResult && record.usage) {
+        if (contextHubResult && (record.usage || record.requestTraces?.length)) {
           try {
             const contextHubSnapshot = await persistContextHubProviderUsage(
               getContextHub(pp),
@@ -1985,6 +1994,12 @@ export function ChatPanel() {
                 requestDiagnostics: buildLlmRequestDiagnostics(
                   record.usage,
                   Math.max(1, record.roundsUsed || 1),
+                  {
+                    requests: record.requestTraces,
+                    omittedRequestCount: record.omittedRequestTraceCount,
+                    requestCountAvailable: record.providerRequestCountAvailable,
+                    usageScope: record.usageAggregationScope,
+                  },
                 ),
               },
             )

+ 122 - 1
src/components/chat/context-trace-panel.spec.tsx

@@ -43,7 +43,8 @@ describe("ContextTracePanel selected skills", () => {
     expect(html).toContain("会话摘要 180 Token")
     expect(html).toContain("动态片段 420 Token")
     expect(html).toContain("上下文压缩预计减少 1,400 Token(44%)")
-    expect(html).toContain("已发送稳定前缀,是否命中以供应商返回为准")
+    expect(html).toContain("已发送本地稳定核心,是否命中以供应商返回为准")
+    expect(html).toContain("供应商前缀:不可判断")
     expect(html).toContain("实际用量不可用")
     expect(html).not.toContain("供应商已确认命中")
   })
@@ -88,6 +89,54 @@ describe("ContextTracePanel selected skills", () => {
     expect(html).toContain("供应商新写入缓存 256 Token")
   })
 
+  it("labels Codex thread totals and does not show the outer round as a request count", () => {
+    const trace: ContextTrace = {
+      id: "trace-codex-thread-total",
+      startedAt: 1,
+      status: "done",
+      toolCalls: [],
+      contextInfo: {
+        intent: "write_chapter",
+        confidence: 1,
+        routeSource: "default",
+        loadedSources: [],
+        blockedSources: [],
+        retrievalHits: [],
+        trimmedSections: [],
+        contextHub: {
+          cacheHits: 1, reloaded: 0, empty: 0, fallbackUsed: 0, readFailed: 0, writeFailed: 0,
+          stableTokens: 100,
+          summaryTokens: 0,
+          dynamicTokens: 20,
+          candidateTokens: 120,
+          estimatedSavedTokens: 0,
+          estimatedSavedPercent: 0,
+          expanded: false,
+          providerCacheEnabled: true,
+          providerUsageReported: true,
+          providerInputTokens: 125_732,
+          providerCachedTokens: 120_576,
+          requestDiagnostics: {
+            requestCount: 0,
+            requestCountAvailable: false,
+            usageScope: "provider_thread",
+            providerUsageAvailable: true,
+            inputTokens: 3_676_375,
+            outputTokens: 7_926,
+            cacheReadTokens: 3_533_312,
+            cacheWriteTokens: 0,
+          },
+        },
+      },
+    }
+
+    const html = renderToStaticMarkup(<ContextTracePanel trace={trace} />)
+
+    expect(html).toContain("Codex 线程累计实际用量:内部请求数不可判断")
+    expect(html).toContain("输入 3,676,375")
+    expect(html).not.toContain("请求 1")
+  })
+
   it("uses the shared cache viewer when a persisted snapshot reference exists", () => {
     const trace: ContextTrace = {
       id: "trace-snapshot",
@@ -129,6 +178,78 @@ describe("ContextTracePanel selected skills", () => {
     expect(html).toContain("本轮数据源:命中 2,重载 1,无数据 0,fallback 0,失败 0")
   })
 
+  it("renders sanitized per-request prefix, timing and cache diagnostics", () => {
+    const trace: ContextTrace = {
+      id: "trace-request-cache",
+      startedAt: 1,
+      status: "done",
+      toolCalls: [],
+      contextInfo: {
+        intent: "write_chapter",
+        confidence: 1,
+        routeSource: "default",
+        loadedSources: [],
+        blockedSources: [],
+        retrievalHits: [],
+        trimmedSections: [],
+        contextHub: {
+          cacheHits: 1, reloaded: 0, empty: 0, fallbackUsed: 0, readFailed: 0, writeFailed: 0,
+          stableTokens: 100,
+          summaryTokens: 20,
+          dynamicTokens: 30,
+          candidateTokens: 200,
+          estimatedSavedTokens: 50,
+          estimatedSavedPercent: 25,
+          expanded: false,
+          providerCacheEnabled: true,
+          requestDiagnostics: {
+            requestCount: 2,
+            providerUsageAvailable: true,
+            requests: [
+              {
+                provider: "openai",
+                model: "gpt-test",
+                apiMode: "chat_completions",
+                prefixFingerprint: "abcdef0123456789",
+                startedAt: 1_000,
+                finishedAt: 1_400,
+                durationMs: 400,
+                firstResponseMs: 120,
+                cacheReadTokens: 0,
+                cacheWriteTokens: 500,
+                status: "success",
+              },
+              {
+                provider: "openai",
+                model: "gpt-test",
+                apiMode: "chat_completions",
+                prefixFingerprint: "abcdef0123456789",
+                startedAt: 2_000,
+                finishedAt: 2_300,
+                durationMs: 300,
+                firstResponseMs: 80,
+                startGapMs: 1_000,
+                idleGapMs: 600,
+                cacheReadTokens: 500,
+                cacheWriteTokens: 0,
+                status: "success",
+              },
+            ],
+            omittedRequestCount: 4,
+          },
+        },
+      },
+    }
+
+    const html = renderToStaticMarkup(<ContextTracePanel trace={trace} />)
+
+    expect(html).toContain("供应商前缀:未变化")
+    expect(html).toContain("请求缓存与间隔(2,另省略 4)")
+    expect(html).toContain("开始间隔")
+    expect(html).toContain("TTFT")
+    expect(html).toContain("abcdef0123")
+  })
+
   it("renders web search trace entries in the overview", () => {
     const trace: ContextTrace = {
       id: "trace-web",

+ 95 - 3
src/components/common/context-hub-stats-summary.tsx

@@ -2,6 +2,7 @@ import type {
   ContextHubStats,
   StablePrefixStatus,
 } from "@/lib/context-hub/types"
+import type { LlmRequestCacheTrace } from "@/lib/llm-request-trace"
 
 const STABLE_PREFIX_LABELS: Record<StablePrefixStatus, string> = {
   unchanged: "未变化",
@@ -23,6 +24,35 @@ function formatTokens(tokens: number): string {
   return `${tokens.toLocaleString()} Token`
 }
 
+function formatDuration(milliseconds: number | undefined): string {
+  if (milliseconds === undefined) return "—"
+  if (milliseconds < 1_000) return `${Math.round(milliseconds)} ms`
+  return `${(milliseconds / 1_000).toFixed(milliseconds < 10_000 ? 2 : 1)} s`
+}
+
+function getProviderPrefixStatus(requests: LlmRequestCacheTrace[]): "未变化" | "已变化" | "不可判断" {
+  const fingerprints = requests
+    .map((request) => request.prefixFingerprint)
+    .filter((value): value is string => Boolean(value))
+  if (fingerprints.length < 2) return "不可判断"
+  return new Set(fingerprints).size === 1 ? "未变化" : "已变化"
+}
+
+function requestPrefixStatus(
+  request: LlmRequestCacheTrace,
+  previous: LlmRequestCacheTrace | undefined,
+): "未变化" | "已变化" | "不可判断" {
+  if (!request.prefixFingerprint || !previous?.prefixFingerprint) return "不可判断"
+  return request.prefixFingerprint === previous.prefixFingerprint ? "未变化" : "已变化"
+}
+
+const REQUEST_STATUS_LABELS: Record<LlmRequestCacheTrace["status"], string> = {
+  success: "成功",
+  error: "供应商错误",
+  cancelled: "已取消",
+  network_error: "网络错误",
+}
+
 export function ProviderCacheUsage({ stats }: { stats: ContextHubStats }) {
   const cachedTokens = stats.providerCachedTokens
   const inputTokens = stats.providerInputTokens
@@ -44,7 +74,7 @@ export function ProviderCacheUsage({ stats }: { stats: ContextHubStats }) {
       ) : stats.providerUsageReported ? (
         <div>供应商已返回 Token 用量,但未提供缓存命中明细</div>
       ) : stats.providerCacheEnabled ? (
-        <div>已发送稳定前缀,是否命中以供应商返回为准</div>
+        <div>已发送本地稳定核心,是否命中以供应商返回为准</div>
       ) : null}
       {(stats.providerCacheWriteTokens ?? 0) > 0 && (
         <div>供应商新写入缓存 {stats.providerCacheWriteTokens?.toLocaleString()} Token</div>
@@ -62,6 +92,9 @@ export function ContextHubStatsSummary({
 }: ContextHubStatsSummaryProps) {
   const failures = stats.readFailed + stats.writeFailed
   const diagnostics = stats.requestDiagnostics
+  const usageScopeLabel = diagnostics?.usageScope === "provider_thread"
+    ? "Codex 线程累计实际用量"
+    : "工作流累计实际用量"
 
   return (
     <div className={className}>
@@ -70,7 +103,7 @@ export function ContextHubStatsSummary({
       </div>
       {stats.stablePrefixStatus ? (
         <div className="mt-0.5 text-[11px] text-muted-foreground">
-          稳定前缀:{STABLE_PREFIX_LABELS[stats.stablePrefixStatus]}
+          本地稳定核心:{STABLE_PREFIX_LABELS[stats.stablePrefixStatus]}
         </div>
       ) : null}
       <div className="mt-0.5 text-[11px] text-muted-foreground">
@@ -97,7 +130,9 @@ export function ContextHubStatsSummary({
         {diagnostics ? (
           diagnostics.providerUsageAvailable ? (
             <div>
-              工作流累计实际用量:请求 {diagnostics.requestCount},
+              {usageScopeLabel}:{diagnostics.requestCountAvailable === false
+                ? "内部请求数不可判断"
+                : `请求 ${diagnostics.requestCount}`},
               输入 {(diagnostics.inputTokens ?? 0).toLocaleString()},
               输出 {(diagnostics.outputTokens ?? 0).toLocaleString()},
               缓存读 {(diagnostics.cacheReadTokens ?? 0).toLocaleString()},
@@ -116,6 +151,63 @@ export function ContextHubStatsSummary({
         ) : (
           <div>实际用量不可用</div>
         )}
+        <div>
+          供应商前缀:{getProviderPrefixStatus(diagnostics?.requests ?? [])}
+        </div>
+        {(diagnostics?.requests?.length ?? 0) > 0 ? (
+          <details className="mt-1">
+            <summary className="cursor-pointer select-none font-medium">
+              请求缓存与间隔({diagnostics?.requests?.length ?? 0}
+              {(diagnostics?.omittedRequestCount ?? 0) > 0
+                ? `,另省略 ${diagnostics?.omittedRequestCount}`
+                : ""})
+            </summary>
+            <div className="mt-1 overflow-x-auto">
+              <table className="w-full min-w-[720px] border-collapse text-left text-[10px]">
+                <thead>
+                  <tr className="border-b border-border/60">
+                    <th className="py-1 pr-2 font-medium">请求</th>
+                    <th className="py-1 pr-2 font-medium">供应商前缀</th>
+                    <th className="py-1 pr-2 font-medium">开始间隔</th>
+                    <th className="py-1 pr-2 font-medium">空闲间隔</th>
+                    <th className="py-1 pr-2 font-medium">耗时</th>
+                    <th className="py-1 pr-2 font-medium">TTFT</th>
+                    <th className="py-1 pr-2 font-medium">输入/输出</th>
+                    <th className="py-1 pr-2 font-medium">缓存读/写</th>
+                    <th className="py-1 font-medium">状态</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  {diagnostics?.requests?.map((request, index, requests) => (
+                    <tr
+                      key={`${request.startedAt}:${index}`}
+                      className="border-b border-border/30 last:border-b-0"
+                    >
+                      <td className="whitespace-nowrap py-1 pr-2">
+                        #{index + 1} {request.provider}/{request.model}
+                      </td>
+                      <td className="whitespace-nowrap py-1 pr-2">
+                        {requestPrefixStatus(request, requests[index - 1])}
+                        {request.prefixFingerprint ? ` · ${request.prefixFingerprint.slice(0, 10)}` : ""}
+                      </td>
+                      <td className="whitespace-nowrap py-1 pr-2">{formatDuration(request.startGapMs)}</td>
+                      <td className="whitespace-nowrap py-1 pr-2">{formatDuration(request.idleGapMs)}</td>
+                      <td className="whitespace-nowrap py-1 pr-2">{formatDuration(request.durationMs)}</td>
+                      <td className="whitespace-nowrap py-1 pr-2">{formatDuration(request.firstResponseMs)}</td>
+                      <td className="whitespace-nowrap py-1 pr-2">
+                        {request.inputTokens?.toLocaleString() ?? "—"}/{request.outputTokens?.toLocaleString() ?? "—"}
+                      </td>
+                      <td className="whitespace-nowrap py-1 pr-2">
+                        {request.cacheReadTokens?.toLocaleString() ?? "—"}/{request.cacheWriteTokens?.toLocaleString() ?? "—"}
+                      </td>
+                      <td className="whitespace-nowrap py-1">{REQUEST_STATUS_LABELS[request.status]}</td>
+                    </tr>
+                  ))}
+                </tbody>
+              </table>
+            </div>
+          </details>
+        ) : null}
       </div>
       {warnings.length > 0 ? (
         <div className="mt-1 space-y-0.5 text-[11px] text-amber-700 dark:text-amber-300">

+ 40 - 6
src/components/sources/outline-chat-panel.tsx

@@ -203,6 +203,7 @@ import {
 } from "@/lib/context-usage";
 import { selectContextHistoryMessages } from "@/lib/context-hub/session-summary";
 import { addLlmUsage, type LlmUsage } from "@/lib/llm-usage";
+import { LlmRequestTraceCollector } from "@/lib/llm-request-trace";
 import { enqueueUserMemoryLearning } from "@/lib/user-memory/learning-service";
 import { recordLatestUserMemoryFeedback } from "@/lib/user-memory/feedback-service";
 import {
@@ -1978,6 +1979,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       let lastProviderUsage: LlmUsage | undefined;
       let memoryDecision: UserMemoryDecision | null | undefined;
       let llmRequestCount = 0;
+      let providerRequestCountAvailable = true;
+      const requestTraceCollector = new LlmRequestTraceCollector();
       let accumulatedReasoningContent = "";
       const missingSkillNames = new Set<string>();
       // 已生成的用户可见文本。streamingContents 只承载状态提示不存内容,
@@ -2251,6 +2254,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 }));
               },
               onDone: () => {},
+              onRequestTrace: requestTraceCollector.record,
               onError: (error) => {
                 agentErrorBox.current = error;
               },
@@ -2259,7 +2263,11 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           );
           providerUsage = addLlmUsage(providerUsage, record.usage);
           lastProviderUsage = record.lastRequestUsage ?? record.usage ?? lastProviderUsage;
-          llmRequestCount += Math.max(1, record.roundsUsed || 1);
+          if (record.providerRequestCountAvailable === false) {
+            providerRequestCountAvailable = false;
+          } else {
+            llmRequestCount += Math.max(1, record.roundsUsed || 1);
+          }
           if (memoryDecision === undefined && record.userMemoryDecision !== undefined) {
             memoryDecision = record.userMemoryDecision;
           }
@@ -2687,7 +2695,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           }
           return { started: true, sent: false };
         }
-        if (contextHubResult && providerUsage) {
+        if (contextHubResult && (providerUsage || requestTraceCollector.snapshot().requests.length > 0)) {
           try {
             const contextHubSnapshot = await persistContextHubProviderUsage(
               getContextHub(normalizePath(project.path)),
@@ -2699,6 +2707,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 requestDiagnostics: buildLlmRequestDiagnostics(
                   providerUsage,
                   Math.max(1, llmRequestCount || 1),
+                  {
+                    ...requestTraceCollector.snapshot(),
+                    requestCountAvailable: providerRequestCountAvailable,
+                  },
                 ),
               },
             );
@@ -3209,6 +3221,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         let lastProviderUsage: LlmUsage | undefined;
         let memoryDecision: UserMemoryDecision | null | undefined;
         let llmRequestCount = 0;
+        let providerRequestCountAvailable = true;
+        const requestTraceCollector = new LlmRequestTraceCollector();
         try {
           const contextHub = getContextHub(normalizePath(project.path));
           contextHubResult = await contextHub.prepare({
@@ -3342,11 +3356,16 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               onToolError: () => {},
               onToolEvent: () => {},
               onDone: () => {},
+              onRequestTrace: requestTraceCollector.record,
               onError: (error) => { agentError = error; },
             }, controller.signal);
             providerUsage = addLlmUsage(providerUsage, record.usage);
             lastProviderUsage = record.lastRequestUsage ?? record.usage ?? lastProviderUsage;
-            llmRequestCount += Math.max(1, record.roundsUsed || 1);
+            if (record.providerRequestCountAvailable === false) {
+              providerRequestCountAvailable = false;
+            } else {
+              llmRequestCount += Math.max(1, record.roundsUsed || 1);
+            }
             if (memoryDecision === undefined && record.userMemoryDecision !== undefined) {
               memoryDecision = record.userMemoryDecision;
             }
@@ -3381,11 +3400,16 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               onToolError: () => {},
               onToolEvent: () => {},
               onDone: () => {},
+              onRequestTrace: requestTraceCollector.record,
               onError: (error) => { mergeError = error; },
             }, controller.signal);
             providerUsage = addLlmUsage(providerUsage, record.usage);
             lastProviderUsage = record.lastRequestUsage ?? record.usage ?? lastProviderUsage;
-            llmRequestCount += Math.max(1, record.roundsUsed || 1);
+            if (record.providerRequestCountAvailable === false) {
+              providerRequestCountAvailable = false;
+            } else {
+              llmRequestCount += Math.max(1, record.roundsUsed || 1);
+            }
             if (memoryDecision === undefined && record.userMemoryDecision !== undefined) {
               memoryDecision = record.userMemoryDecision;
             }
@@ -3407,7 +3431,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
 
         if (!isCurrentRun()) return;
 
-        if (contextHubResult && providerUsage) {
+        if (contextHubResult && (providerUsage || requestTraceCollector.snapshot().requests.length > 0)) {
           try {
             const contextHubSnapshot = await persistContextHubProviderUsage(
               getContextHub(normalizePath(project.path)),
@@ -3419,6 +3443,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 requestDiagnostics: buildLlmRequestDiagnostics(
                   providerUsage,
                   Math.max(1, llmRequestCount || 1),
+                  {
+                    ...requestTraceCollector.snapshot(),
+                    requestCountAvailable: providerRequestCountAvailable,
+                  },
                 ),
               },
             );
@@ -3838,7 +3866,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         );
         if (agentError) throw agentError;
         if (!isCurrentRun()) return;
-        if (contextHubResult && record.usage) {
+        if (contextHubResult && (record.usage || record.requestTraces?.length)) {
           try {
             const updatedSnapshot = await persistContextHubProviderUsage(
               getContextHub(normalizePath(project.path)),
@@ -3850,6 +3878,12 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 requestDiagnostics: buildLlmRequestDiagnostics(
                   record.usage,
                   Math.max(1, record.roundsUsed || 1),
+                  {
+                    requests: record.requestTraces,
+                    omittedRequestCount: record.omittedRequestTraceCount,
+                    requestCountAvailable: record.providerRequestCountAvailable,
+                    usageScope: record.usageAggregationScope,
+                  },
                 ),
               },
             );

+ 2 - 0
src/lib/agent/codex-app-server-runner.spec.ts

@@ -148,6 +148,8 @@ describe("CodexAppServerRunner", () => {
     expect(record.toolCalls[0]).toEqual(expect.objectContaining({ name: "read_outline", status: "done" }))
     expect(record.lastRequestUsage).toEqual(expect.objectContaining({ totalTokens: 12 }))
     expect(record.usage).toEqual(expect.objectContaining({ totalTokens: 22 }))
+    expect(record.usageAggregationScope).toBe("provider_thread")
+    expect(record.providerRequestCountAvailable).toBe(false)
     expect(cb.onText).toHaveBeenCalledWith("最终回答")
     expect(cb.onDone).toHaveBeenCalledOnce()
     expect(cb.onError).not.toHaveBeenCalled()

+ 7 - 1
src/lib/agent/codex-app-server-runner.ts

@@ -104,7 +104,13 @@ export class CodexAppServerRunner {
     callbacks: AgentRunCallbacks,
     signal?: AbortSignal,
   ): Promise<AgentRunRecord> {
-    const record: AgentRunRecord = { toolCalls: [], roundsUsed: 0, finalText: "" }
+    const record: AgentRunRecord = {
+      toolCalls: [],
+      roundsUsed: 0,
+      finalText: "",
+      usageAggregationScope: "provider_thread",
+      providerRequestCountAvailable: false,
+    }
     const client = getCodexAppServerClient()
     const evidenceLedger = new ToolEvidenceLedger(config.toolResultContextLimit ?? 6000)
     let threadId = ""

+ 4 - 0
src/lib/agent/pipeline.ts

@@ -26,6 +26,8 @@ export interface PrePluginInput {
   novelSystemPrompt?: string
   finalSystemPrompt?: string
   finalSystemRulesPrompt?: string
+  stableSystemRulesPrompt?: string
+  dynamicSystemRulesPrompt?: string
   shouldStop?: boolean
   stopReason?: string
   [key: string]: unknown
@@ -41,6 +43,8 @@ export interface PrePluginOutput {
   novelSystemPrompt?: string
   finalSystemPrompt?: string
   finalSystemRulesPrompt?: string
+  stableSystemRulesPrompt?: string
+  dynamicSystemRulesPrompt?: string
   shouldStop?: boolean
   stopReason?: string
   [key: string]: unknown

+ 4 - 0
src/lib/agent/plugins/build-system-prompt-plugin.spec.ts

@@ -40,6 +40,10 @@ describe("BuildSystemPromptPlugin selected skills", () => {
     expect(result.finalSystemRulesPrompt).toContain("本次启用 Skill")
     expect(result.finalSystemRulesPrompt).toContain("task directive")
     expect(result.finalSystemRulesPrompt).not.toContain("context prompt")
+    expect(result.stableSystemRulesPrompt).toBe("base prompt")
+    expect(result.dynamicSystemRulesPrompt).toContain("本次启用 Skill")
+    expect(result.dynamicSystemRulesPrompt).toContain("task directive")
+    expect(result.dynamicSystemRulesPrompt).not.toContain("base prompt")
   })
 
   it("does not inject chapter plan protocol from standard mode unless Plan Execute is enabled", async () => {

+ 14 - 1
src/lib/agent/plugins/build-system-prompt-plugin.ts

@@ -29,6 +29,8 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
 
         const parts: string[] = []
         const rulesParts: string[] = []
+        const stableRulesParts: string[] = []
+        const dynamicRulesParts: string[] = []
 
         // 去掉 base 里可能已有的找纲协议,统一由本 plugin 注入一次,避免重复。
         const rawBase = baseSystemPrompt || (input.agentConfig as any)?.systemPrompt || ""
@@ -36,6 +38,7 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
         if (base) {
           parts.push(base)
           rulesParts.push(base)
+          stableRulesParts.push(base)
         }
 
         if (input.novelSystemPrompt) {
@@ -46,6 +49,7 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
         if (selectedSkillsPrompt) {
           parts.push(selectedSkillsPrompt)
           rulesParts.push(selectedSkillsPrompt)
+          dynamicRulesParts.push(selectedSkillsPrompt)
         }
         const missingSkillNames = Array.isArray(input.missingSkillNames)
           ? input.missingSkillNames.filter((name): name is string => typeof name === "string")
@@ -54,6 +58,7 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
           const diagnostic = `## Skill 路由诊断\n以下确定性 Skill 缺失或已被用户禁用,禁止通过 apply_skill 强制启用:${missingSkillNames.join("、")}。请按已加载规则继续,并向用户保留该诊断。`
           parts.push(diagnostic)
           rulesParts.push(diagnostic)
+          dynamicRulesParts.push(diagnostic)
         }
 
         const routeForWriting = input.effectiveTaskRoute || input.taskRoute
@@ -62,6 +67,7 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
           const outlineProtocol = buildOutlineFindProtocol(routeForWriting?.chapterNumber)
           parts.push(outlineProtocol)
           rulesParts.push(outlineProtocol)
+          dynamicRulesParts.push(outlineProtocol)
         }
 
         if (input.planExecuteEnabled && input.aiWorkflowMode) {
@@ -72,6 +78,7 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
           const planProtocol = buildChapterPlanProtocol(input.aiWorkflowMode)
           parts.push(planProtocol)
           rulesParts.push(planProtocol)
+          dynamicRulesParts.push(planProtocol)
         }
 
         if (route) {
@@ -79,12 +86,18 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
           if (taskDirective) {
             parts.push(taskDirective)
             rulesParts.push(taskDirective)
+            dynamicRulesParts.push(taskDirective)
           }
         }
 
         const finalSystemPrompt = parts.join("\n\n")
         const finalSystemRulesPrompt = rulesParts.join("\n\n")
-        return { finalSystemPrompt, finalSystemRulesPrompt }
+        return {
+          finalSystemPrompt,
+          finalSystemRulesPrompt,
+          stableSystemRulesPrompt: stableRulesParts.join("\n\n"),
+          dynamicSystemRulesPrompt: dynamicRulesParts.join("\n\n"),
+        }
       } catch (error) {
         onError?.(error instanceof Error ? error : new Error(String(error)))
         return {}

+ 11 - 0
src/lib/agent/runner.spec.ts

@@ -69,6 +69,16 @@ describe("AgentRunner", () => {
 
   it("returns final text when LLM responds without tool calls", async () => {
     mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      cb.onRequestTrace?.({
+        provider: "openai",
+        model: "test",
+        apiMode: "chat_completions",
+        prefixFingerprint: "fingerprint",
+        startedAt: 100,
+        finishedAt: 200,
+        durationMs: 100,
+        status: "success",
+      })
       for (const char of "Hello user!") {
         cb.onToken(char)
       }
@@ -86,6 +96,7 @@ describe("AgentRunner", () => {
     const result = await runner.run(config, registry, [systemMsg, userMsg], callbacks, undefined)
     expect(result.finalText).toBe("Hello user!")
     expect(result.roundsUsed).toBe(1)
+    expect(result.requestTraces).toEqual([expect.objectContaining({ prefixFingerprint: "fingerprint" })])
     expect(callbacks.onDone).toHaveBeenCalledOnce()
     expect(callbacks.onError).not.toHaveBeenCalled()
   })

+ 11 - 1
src/lib/agent/runner.ts

@@ -16,6 +16,7 @@ import {
 import { getEffectiveMaxContextSize, type ChatMessage } from "../llm-providers"
 import { isReasoningDisabled, isReasoningOnlyResponseError, withReasoningDisabled } from "../reasoning-retry"
 import { addLlmUsage, mergeLlmUsageSnapshot, type LlmUsage } from "../llm-usage"
+import { LlmRequestTraceCollector, type LlmRequestCacheTrace } from "../llm-request-trace"
 import { trimChatMessagesToTokenBudget } from "../chat-request-budget"
 import { logReasoningReplay } from "../reasoning-replay-debug"
 import { ToolEvidenceLedger } from "./tool-evidence-ledger"
@@ -53,6 +54,14 @@ export class AgentRunner {
       return new CodexAppServerRunner().run(config, registry, messages, callbacks, signal)
     }
     const record: AgentRunRecord = { toolCalls: [], roundsUsed: 0, finalText: "" }
+    const requestTraceCollector = new LlmRequestTraceCollector()
+    const onRequestTrace = (trace: LlmRequestCacheTrace) => {
+      requestTraceCollector.record(trace)
+      const snapshot = requestTraceCollector.snapshot()
+      record.requestTraces = snapshot.requests
+      record.omittedRequestTraceCount = snapshot.omittedRequestCount
+      callbacks.onRequestTrace?.(trace)
+    }
     const workingMessages = [...messages]
     let finalText = ""
     const maxRounds = config.maxRounds || DEFAULT_MAX_ROUNDS
@@ -139,6 +148,7 @@ export class AgentRunner {
           roundUsage = mergeLlmUsageSnapshot(roundUsage, usage)
           if (roundUsage) callbacks.onUsage?.(roundUsage)
         },
+        onRequestTrace,
         onUserMemoryDecision: (decision) => {
           if (record.userMemoryDecision === undefined) {
             record.userMemoryDecision = decision
@@ -373,7 +383,7 @@ export class AgentRunner {
         const executed = await executeAgentTool(
           { id: tc.id, name: toolName, arguments: params } satisfies ToolCall,
           registry,
-          callbacks,
+          { ...callbacks, onRequestTrace },
           signal,
         )
         record.toolCalls.push(executed.record)

+ 1 - 0
src/lib/agent/tool-executor.ts

@@ -75,6 +75,7 @@ export async function executeAgentTool(
     toolName: call.name,
     onToolEvent: callbacks.onToolEvent,
     onActivityEvent: callbacks.onActivityEvent,
+    onRequestTrace: callbacks.onRequestTrace,
   }
   const permission = tool.permission ?? (tool.category === "write" ? "confirm" : "auto")
   if (permission === "confirm") {

+ 38 - 0
src/lib/agent/tools/run-chapter-workflow.spec.ts

@@ -184,6 +184,44 @@ describe("createRunChapterWorkflowTool", () => {
     }))
   })
 
+  it("forwards every nested chapter request trace into the parent Agent collector", async () => {
+    const onRequestTrace = vi.fn()
+    const trace = {
+      provider: "custom" as const,
+      model: "test-model",
+      apiMode: "chat_completions",
+      startedAt: 100,
+      finishedAt: 200,
+      durationMs: 100,
+      status: "success" as const,
+    }
+    const runDeepChapterGeneration = vi.fn(async (_input, callbacks) => {
+      callbacks.onRequestTrace?.(trace)
+      return {
+        finalContent: "正文",
+        taskBrief: "任务书",
+        draftContent: "草稿",
+        reviewResults: [],
+        revised: false,
+      }
+    })
+    const tool = createRunChapterWorkflowTool({
+      projectPath: "C:/Novel",
+      llmConfig,
+      aiWorkflowMode: "strict",
+      runDeepChapterGeneration,
+    })
+
+    await tool.execute(
+      { userRequest: "生成第14章" },
+      undefined,
+      { callId: "workflow-trace", toolName: "run_chapter_workflow", onRequestTrace },
+    )
+
+    expect(onRequestTrace).toHaveBeenCalledOnce()
+    expect(onRequestTrace).toHaveBeenCalledWith(trace)
+  })
+
   it("forwards the confirmed plan blueprint into deep chapter generation", async () => {
     const runDeepChapterGeneration = vi.fn(async (_input, callbacks) => {
       callbacks.onWorkflowEvent?.({

+ 1 - 0
src/lib/agent/tools/run-chapter-workflow.ts

@@ -145,6 +145,7 @@ export function createRunChapterWorkflowTool(options: RunChapterWorkflowToolOpti
               toolCallId: event.toolCallId ?? parentCallId,
             })
           },
+          onRequestTrace: context?.onRequestTrace,
         },
         undefined,
         signal,

+ 9 - 0
src/lib/agent/types.ts

@@ -1,6 +1,7 @@
 import type { LlmConfig } from "@/stores/wiki-store"
 import type { ChatMessage, RequestOverrides } from "../llm-providers"
 import type { LlmUsage } from "../llm-usage"
+import type { LlmRequestCacheTrace } from "../llm-request-trace"
 
 export interface ToolParameter {
   type: "string" | "number" | "boolean" | "object" | "array" | "integer"
@@ -18,6 +19,7 @@ export interface ToolExecutionContext {
   toolName: string
   onToolEvent?: (event: AgentToolEvent) => void
   onActivityEvent?: (event: AgentActivityEvent) => void
+  onRequestTrace?: (trace: LlmRequestCacheTrace) => void
 }
 
 export interface Tool {
@@ -136,6 +138,7 @@ export interface AgentRunCallbacks {
   onActivityEvent?: (event: AgentActivityEvent) => void
   /** Usage for the current/latest provider request. */
   onUsage?: (usage: LlmUsage) => void
+  onRequestTrace?: (trace: LlmRequestCacheTrace) => void
   onUserMemoryDecision?: (decision: import("@/lib/user-memory/decision-trace").UserMemoryDecision | null) => void
   onDone: () => void
   onError: (error: Error) => void
@@ -166,8 +169,14 @@ export interface AgentRunRecord {
   finalText: string
   /** Cumulative provider usage across all requests in this agent run. */
   usage?: LlmUsage
+  /** Provider-managed thread totals cannot be assigned a reliable internal request count. */
+  usageAggregationScope?: "workflow" | "provider_thread"
+  providerRequestCountAvailable?: boolean
   /** Provider usage for the final request only; used for context-window UI. */
   lastRequestUsage?: LlmUsage
+  /** Sanitized request traces for this run, including nested workflow calls. */
+  requestTraces?: LlmRequestCacheTrace[]
+  omittedRequestTraceCount?: number
   /** Memory decision from the first LLM round that applied user memory. */
   userMemoryDecision?: import("@/lib/user-memory/decision-trace").UserMemoryDecision | null
 }

+ 97 - 0
src/lib/context-hub/normalize-stats.spec.ts

@@ -101,6 +101,103 @@ describe("parseContextHubSnapshot", () => {
     expect(snapshot?.stats.cacheHits).toBe(1)
     expect(snapshot?.items[0]?.status).toBe("cache_hit")
   })
+
+  it("keeps valid request traces and drops damaged optional trace entries", () => {
+    const snapshot = parseContextHubSnapshot({
+      schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      id: "assistant:trace",
+      surface: "ai-chat",
+      createdAt: 10,
+      stats: {
+        ...currentStats,
+        requestDiagnostics: {
+          requestCount: 2,
+          providerUsageAvailable: true,
+          requests: [
+            {
+              provider: "openai",
+              model: "gpt-test",
+              apiMode: "chat_completions",
+              startedAt: 1,
+              finishedAt: 2,
+              durationMs: 1,
+              status: "success",
+              prompt: "不应保留",
+            },
+            {
+              provider: "openai",
+              model: "gpt-test",
+              apiMode: "chat_completions",
+              startedAt: 1,
+              finishedAt: 2,
+              durationMs: -1,
+              status: "success",
+              prompt: "不应保留",
+            },
+          ],
+          omittedRequestCount: 3,
+        },
+      },
+      items: [],
+      stableCore: "stable",
+      sessionSummary: "",
+      dynamicContext: "dynamic",
+    })
+
+    expect(snapshot?.stats.requestDiagnostics?.requests).toHaveLength(1)
+    expect(snapshot?.stats.requestDiagnostics?.omittedRequestCount).toBe(3)
+    expect(snapshot?.stats.requestDiagnostics?.requests?.[0]).not.toHaveProperty("prompt")
+  })
+
+  it("keeps valid aggregate scope metadata and drops invalid optional values", () => {
+    const valid = parseContextHubSnapshot({
+      schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      id: "assistant:codex-usage",
+      surface: "ai-chat",
+      createdAt: 10,
+      stats: {
+        ...currentStats,
+        requestDiagnostics: {
+          requestCount: 0,
+          requestCountAvailable: false,
+          usageScope: "provider_thread",
+          providerUsageAvailable: true,
+          inputTokens: 100,
+        },
+      },
+      items: [],
+      stableCore: "stable",
+      sessionSummary: "",
+      dynamicContext: "dynamic",
+    })
+    const damaged = parseContextHubSnapshot({
+      schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      id: "assistant:damaged-usage-scope",
+      surface: "ai-chat",
+      createdAt: 11,
+      stats: {
+        ...currentStats,
+        requestDiagnostics: {
+          requestCount: 1,
+          requestCountAvailable: "no",
+          usageScope: "single_request",
+          providerUsageAvailable: true,
+          inputTokens: 100,
+        },
+      },
+      items: [],
+      stableCore: "stable",
+      sessionSummary: "",
+      dynamicContext: "dynamic",
+    })
+
+    expect(valid?.stats.requestDiagnostics).toMatchObject({
+      requestCountAvailable: false,
+      usageScope: "provider_thread",
+    })
+    expect(damaged?.stats.requestDiagnostics).not.toHaveProperty("requestCountAvailable")
+    expect(damaged?.stats.requestDiagnostics).not.toHaveProperty("usageScope")
+  })
 })
 
 describe("parseContextHubSnapshotRef", () => {

+ 61 - 0
src/lib/context-hub/provider-usage.spec.ts

@@ -26,6 +26,67 @@ const baseStats: ContextHubStats = {
 }
 
 describe("context hub provider usage", () => {
+  it("marks provider-thread totals without inventing an internal request count", () => {
+    const diagnostics = buildLlmRequestDiagnostics(
+      {
+        inputTokens: 3_676_375,
+        outputTokens: 7_926,
+        cachedInputTokens: 3_533_312,
+      },
+      1,
+      {
+        requestCountAvailable: false,
+        usageScope: "provider_thread",
+      },
+    )
+
+    expect(diagnostics).toMatchObject({
+      requestCount: 0,
+      requestCountAvailable: false,
+      usageScope: "provider_thread",
+      providerUsageAvailable: true,
+      inputTokens: 3_676_375,
+      cacheReadTokens: 3_533_312,
+    })
+  })
+
+  it("uses actual traced attempts for request count and preserves traces while merging usage", () => {
+    const diagnostics = buildLlmRequestDiagnostics(
+      { inputTokens: 100 },
+      1,
+      {
+        requests: [{
+          provider: "openai",
+          model: "gpt-test",
+          apiMode: "chat_completions",
+          startedAt: 1,
+          finishedAt: 2,
+          durationMs: 1,
+          status: "error",
+        }, {
+          provider: "openai",
+          model: "gpt-test",
+          apiMode: "chat_completions",
+          startedAt: 3,
+          finishedAt: 4,
+          durationMs: 1,
+          status: "success",
+        }],
+        omittedRequestCount: 2,
+      },
+    )
+
+    expect(diagnostics.requestCount).toBe(4)
+    expect(applyProviderUsageToStats(
+      { ...baseStats, requestDiagnostics: diagnostics },
+      { inputTokens: 50 },
+    ).requestDiagnostics).toMatchObject({
+      requestCount: 5,
+      requests: diagnostics.requests,
+      omittedRequestCount: 2,
+    })
+  })
+
   it("stores confirmed cache usage without changing local cache counters", () => {
     const next = applyProviderUsageToStats(baseStats, {
       inputTokens: 1600,

+ 35 - 4
src/lib/context-hub/provider-usage.ts

@@ -1,4 +1,8 @@
 import type { LlmUsage } from "@/lib/llm-usage"
+import {
+  copyLlmRequestCacheTrace,
+  type LlmRequestCacheTrace,
+} from "@/lib/llm-request-trace"
 import type { UserMemoryDecision } from "@/lib/user-memory/decision-trace"
 import type {
   ContextHub,
@@ -16,7 +20,23 @@ export interface PersistContextHubProviderUsageOptions {
 export function buildLlmRequestDiagnostics(
   usage: LlmUsage | undefined,
   requestCount = 1,
+  traceOptions: {
+    requests?: LlmRequestCacheTrace[]
+    omittedRequestCount?: number
+    requestCountAvailable?: boolean
+    usageScope?: "workflow" | "provider_thread"
+  } = {},
 ): LlmRequestDiagnostics {
+  const tracedRequestCount = (traceOptions.requests?.length ?? 0)
+    + Math.max(0, traceOptions.omittedRequestCount ?? 0)
+  const requestCountAvailable = traceOptions.requestCountAvailable ?? true
+  const effectiveRequestCount = requestCountAvailable
+    ? (tracedRequestCount > 0 ? tracedRequestCount : requestCount)
+    : 0
+  const scopeFields = {
+    ...(traceOptions.requestCountAvailable !== undefined ? { requestCountAvailable } : {}),
+    ...(traceOptions.usageScope ? { usageScope: traceOptions.usageScope } : {}),
+  }
   const hasAny = Boolean(
     usage
     && (
@@ -28,17 +48,27 @@ export function buildLlmRequestDiagnostics(
   )
   if (!hasAny || !usage) {
     return {
-      requestCount: Math.max(0, requestCount),
+      requestCount: Math.max(0, effectiveRequestCount),
+      ...scopeFields,
       providerUsageAvailable: false,
+      ...(traceOptions.requests ? { requests: traceOptions.requests.map(copyLlmRequestCacheTrace) } : {}),
+      ...(traceOptions.omittedRequestCount !== undefined
+        ? { omittedRequestCount: Math.max(0, traceOptions.omittedRequestCount) }
+        : {}),
     }
   }
   return {
-    requestCount: Math.max(1, requestCount),
+    requestCount: requestCountAvailable ? Math.max(1, effectiveRequestCount) : 0,
+    ...scopeFields,
     providerUsageAvailable: true,
     inputTokens: usage.inputTokens,
     outputTokens: usage.outputTokens,
     cacheReadTokens: usage.cachedInputTokens,
     cacheWriteTokens: usage.cacheWriteInputTokens,
+    ...(traceOptions.requests ? { requests: traceOptions.requests.map(copyLlmRequestCacheTrace) } : {}),
+    ...(traceOptions.omittedRequestCount !== undefined
+      ? { omittedRequestCount: Math.max(0, traceOptions.omittedRequestCount) }
+      : {}),
   }
 }
 
@@ -58,12 +88,13 @@ export function mergeLlmRequestDiagnostics(
   if (!hasAny) {
     return {
       ...base,
-      requestCount: base.requestCount + 1,
+      requestCount: base.requestCountAvailable === false ? base.requestCount : base.requestCount + 1,
       providerUsageAvailable: base.providerUsageAvailable,
     }
   }
   return {
-    requestCount: base.requestCount + 1,
+    ...base,
+    requestCount: base.requestCountAvailable === false ? base.requestCount : base.requestCount + 1,
     providerUsageAvailable: true,
     inputTokens: (base.inputTokens ?? 0) + (usage.inputTokens ?? 0),
     outputTokens: (base.outputTokens ?? 0) + (usage.outputTokens ?? 0),

+ 60 - 2
src/lib/context-hub/types.ts

@@ -1,6 +1,11 @@
 import type { AgentMessage } from "@/lib/agent/types"
 import type { DataSourceCategory } from "@/lib/novel/classification"
 import type { ContextPack } from "@/lib/novel/context-engine"
+import {
+  copyLlmRequestCacheTrace,
+  isLlmRequestCacheTrace,
+  type LlmRequestCacheTrace,
+} from "@/lib/llm-request-trace"
 
 export const CONTEXT_CACHE_SCHEMA_VERSION = 2
 
@@ -94,11 +99,17 @@ export interface ContextFragmentTrace {
 
 export interface LlmRequestDiagnostics {
   requestCount: number
+  /** False when the provider only exposes aggregate usage without an internal request count. */
+  requestCountAvailable?: boolean
+  /** Distinguishes a normal workflow aggregate from a provider-managed thread total. */
+  usageScope?: "workflow" | "provider_thread"
   providerUsageAvailable: boolean
   inputTokens?: number
   outputTokens?: number
   cacheReadTokens?: number
   cacheWriteTokens?: number
+  requests?: LlmRequestCacheTrace[]
+  omittedRequestCount?: number
 }
 
 /** Generation-details stats: source traces + independent stablePrefixStatus; token fields are estimates. */
@@ -247,6 +258,53 @@ function isFiniteNumber(value: unknown): value is number {
   return typeof value === "number" && Number.isFinite(value)
 }
 
+function normalizeRequestDiagnostics(value: unknown): LlmRequestDiagnostics | undefined {
+  if (!value || typeof value !== "object") return undefined
+  const source = value as Record<string, unknown>
+  if (!isFiniteNumber(source.requestCount) || typeof source.providerUsageAvailable !== "boolean") {
+    return undefined
+  }
+  const optionalNumber = (key: string) => source[key] === undefined || isFiniteNumber(source[key])
+  if (
+    !optionalNumber("inputTokens")
+    || !optionalNumber("outputTokens")
+    || !optionalNumber("cacheReadTokens")
+    || !optionalNumber("cacheWriteTokens")
+  ) return undefined
+  const requests = Array.isArray(source.requests)
+    ? source.requests.filter(isLlmRequestCacheTrace).map(copyLlmRequestCacheTrace)
+    : undefined
+  const requestCountAvailable = typeof source.requestCountAvailable === "boolean"
+    ? source.requestCountAvailable
+    : undefined
+  const usageScope = source.usageScope === "workflow" || source.usageScope === "provider_thread"
+    ? source.usageScope
+    : undefined
+  return {
+    requestCount: Math.max(0, Math.floor(source.requestCount)),
+    ...(requestCountAvailable !== undefined ? { requestCountAvailable } : {}),
+    ...(usageScope ? { usageScope } : {}),
+    providerUsageAvailable: source.providerUsageAvailable,
+    ...(isFiniteNumber(source.inputTokens) ? { inputTokens: source.inputTokens } : {}),
+    ...(isFiniteNumber(source.outputTokens) ? { outputTokens: source.outputTokens } : {}),
+    ...(isFiniteNumber(source.cacheReadTokens) ? { cacheReadTokens: source.cacheReadTokens } : {}),
+    ...(isFiniteNumber(source.cacheWriteTokens) ? { cacheWriteTokens: source.cacheWriteTokens } : {}),
+    ...(requests ? { requests } : {}),
+    ...(isFiniteNumber(source.omittedRequestCount)
+      ? { omittedRequestCount: Math.max(0, Math.floor(source.omittedRequestCount)) }
+      : {}),
+  }
+}
+
+function normalizeContextHubStats(source: ContextHubStats): ContextHubStats {
+  const { requestDiagnostics: _requestDiagnostics, ...rest } = source
+  const requestDiagnostics = normalizeRequestDiagnostics(source.requestDiagnostics)
+  return {
+    ...rest,
+    ...(requestDiagnostics ? { requestDiagnostics } : {}),
+  }
+}
+
 /** True only for the current stats shape. Legacy hits/refreshed/failures payloads are rejected. */
 export function isCurrentContextHubStats(raw: unknown): raw is ContextHubStats {
   if (!raw || typeof raw !== "object") return false
@@ -315,7 +373,7 @@ export function parseContextHubSnapshot(raw: unknown): ContextHubSnapshot | null
     id: source.id,
     surface: source.surface,
     createdAt: source.createdAt,
-    stats: source.stats,
+    stats: normalizeContextHubStats(source.stats as ContextHubStats),
     items,
     stableCore: source.stableCore,
     sessionSummary: source.sessionSummary,
@@ -339,6 +397,6 @@ export function parseContextHubSnapshotRef(raw: unknown): ContextHubSnapshotRef
     id: source.id,
     surface: source.surface,
     createdAt: source.createdAt,
-    stats: source.stats,
+    stats: normalizeContextHubStats(source.stats as ContextHubStats),
   }
 }

+ 152 - 6
src/lib/llm-client.ts

@@ -27,6 +27,12 @@ import { RESPONSE_RESERVE_FRAC, planLlmRequestBudget } from "./context-budget"
 import { mergeLlmUsageSnapshot, type LlmUsage } from "./llm-usage"
 import { applyGlobalUserMemoryToMessages } from "./user-memory/request-integration"
 import type { UserMemoryDecision } from "./user-memory/decision-trace"
+import {
+  buildLlmRequestCacheTrace,
+  buildLlmRequestPrefixDescriptor,
+  type LlmRequestCacheTrace,
+  type LlmRequestTraceStatus,
+} from "./llm-request-trace"
 
 export type { ChatMessage, RequestOverrides } from "./llm-providers"
 export { isFetchNetworkError } from "./tauri-fetch"
@@ -38,6 +44,8 @@ export interface StreamCallbacks {
   /** 工具调用流式 delta,用于累积 tool_calls */
   onToolCallDelta?: (delta: { index: number; id?: string; name?: string; arguments?: string }) => void
   onUsage?: (usage: LlmUsage) => void
+  /** Sanitized request-level timing/cache trace; never contains prompt text or credentials. */
+  onRequestTrace?: (trace: LlmRequestCacheTrace) => void
   /** Decision produced while preparing this request's messages (request-scoped). */
   onUserMemoryDecision?: (decision: UserMemoryDecision | null) => void
   onDone: () => void
@@ -262,15 +270,100 @@ export async function streamChat(
   const { onToken, onDone, onError } = callbacks
   const decoder = new TextDecoder()
 
+  let prefixDescriptor = await buildLlmRequestPrefixDescriptor(
+    runtimeConfig,
+    budgetedMessages,
+    effectiveRequestOverrides,
+  )
+  interface ActiveRequestTrace {
+    startedAt: number
+    firstResponseAt?: number
+    finished: boolean
+  }
+  const startRequestTrace = (): ActiveRequestTrace => ({
+    startedAt: Date.now(),
+    finished: false,
+  })
+  const markFirstResponse = (trace: ActiveRequestTrace | null | undefined) => {
+    if (trace && trace.firstResponseAt === undefined) trace.firstResponseAt = Date.now()
+  }
+  const finishRequestTrace = (
+    trace: ActiveRequestTrace | null | undefined,
+    status: LlmRequestTraceStatus,
+    usage?: LlmUsage,
+  ) => {
+    if (!trace || trace.finished) return
+    trace.finished = true
+    try {
+      callbacks.onRequestTrace?.(buildLlmRequestCacheTrace({
+        config: runtimeConfig,
+        ...prefixDescriptor,
+        startedAt: trace.startedAt,
+        finishedAt: Date.now(),
+        firstResponseAt: trace.firstResponseAt,
+        usage,
+        status,
+      }))
+    } catch (error) {
+      console.warn("LLM 请求追踪回调失败,忽略观测错误:", error)
+    }
+  }
+
+  const streamLocalWithTrace = async (
+    run: (localCallbacks: StreamCallbacks) => Promise<void>,
+  ): Promise<void> => {
+    const trace = startRequestTrace()
+    let usage: LlmUsage | undefined
+    const tracedCallbacks: StreamCallbacks = {
+      ...callbacks,
+      onToken: (token) => {
+        markFirstResponse(trace)
+        callbacks.onToken(token)
+      },
+      onReasoningToken: (token) => {
+        markFirstResponse(trace)
+        callbacks.onReasoningToken?.(token)
+      },
+      onToolCallDelta: (delta) => {
+        markFirstResponse(trace)
+        callbacks.onToolCallDelta?.(delta)
+      },
+      onUsage: (nextUsage) => {
+        usage = mergeLlmUsageSnapshot(usage, nextUsage)
+        callbacks.onUsage?.(nextUsage)
+      },
+      onDone: () => {
+        finishRequestTrace(trace, signal?.aborted ? "cancelled" : "success", usage)
+        callbacks.onDone()
+      },
+      onError: (error) => {
+        finishRequestTrace(trace, signal?.aborted ? "cancelled" : "error", usage)
+        callbacks.onError(error)
+      },
+    }
+    try {
+      await run(tracedCallbacks)
+    } catch (error) {
+      finishRequestTrace(
+        trace,
+        signal?.aborted ? "cancelled" : isFetchNetworkError(error) ? "network_error" : "error",
+        usage,
+      )
+      throw error
+    }
+  }
+
   // Claude Code CLI uses a subprocess transport (stdin/stdout), not
   // HTTP. Dispatch before getProviderConfig — that function throws for
   // this provider because it has no URL/headers.
   if (runtimeConfig.provider === "claude-code") {
-    return streamViaClaudeCodeCli(runtimeConfig, budgetedMessages, callbacks, signal, effectiveRequestOverrides)
+    return streamLocalWithTrace((localCallbacks) =>
+      streamViaClaudeCodeCli(runtimeConfig, budgetedMessages, localCallbacks, signal, effectiveRequestOverrides))
   }
 
   if (runtimeConfig.provider === "codex-cli") {
-    return streamViaCodexCli(runtimeConfig, budgetedMessages, callbacks, signal, effectiveRequestOverrides)
+    return streamLocalWithTrace((localCallbacks) =>
+      streamViaCodexCli(runtimeConfig, budgetedMessages, localCallbacks, signal, effectiveRequestOverrides))
   }
 
   if (runtimeConfig.provider === "cursor-cli") {
@@ -310,6 +403,34 @@ export async function streamChat(
   }
 
   try {
+    let activeRequestTrace: ActiveRequestTrace | null = null
+    const tracedFetch = async (
+      fetcher: (url: string, init: RequestInit) => Promise<Response>,
+      url: string,
+      init: RequestInit,
+    ): Promise<Response> => {
+      const trace = startRequestTrace()
+      try {
+        const result = await fetcher(url, init)
+        if (result.ok) {
+          activeRequestTrace = trace
+        } else {
+          finishRequestTrace(trace, "error")
+        }
+        return result
+      } catch (error) {
+        finishRequestTrace(
+          trace,
+          signal?.aborted || (combinedSignal?.aborted && !timeoutFired)
+            ? "cancelled"
+            : isFetchNetworkError(error)
+              ? "network_error"
+              : "error",
+        )
+        throw error
+      }
+    }
+
     const buildRequestInit = (
       nextMessages: import("./llm-providers").ChatMessage[],
       overrides: RequestOverrides = effectiveRequestOverrides,
@@ -325,7 +446,7 @@ export async function streamChat(
       let attempt = 0
       while (true) {
         try {
-          return await httpFetch(providerConfig.url, requestInit)
+          return await tracedFetch(httpFetch, providerConfig.url, requestInit)
         } catch (err) {
           if (signal?.aborted || combinedSignal?.aborted) throw err
           if (!isFetchNetworkError(err)) throw err
@@ -432,6 +553,11 @@ export async function streamChat(
           onError(new Error(inputLengthLimitMessage(inputLimit)))
           return
         }
+        prefixDescriptor = await buildLlmRequestPrefixDescriptor(
+          runtimeConfig,
+          retryMessages,
+          effectiveRequestOverrides,
+        )
         const retryRequestInit = buildRequestInit(retryMessages)
         if (retryRequestInit.body === requestInit.body) {
           onError(new Error(inputLengthLimitMessage(inputLimit)))
@@ -505,7 +631,11 @@ export async function streamChat(
       }
       if (!httpRetrySucceeded && shouldRetryWithBrowserFetch(errorDetail) && typeof globalThis.fetch === "function") {
         try {
-          response = await globalThis.fetch(providerConfig.url, requestInit)
+          response = await tracedFetch(
+            (url, init) => globalThis.fetch(url, init),
+            providerConfig.url,
+            requestInit,
+          )
         } catch (err) {
           onError(err instanceof Error ? err : new Error(String(err)))
           return
@@ -529,6 +659,7 @@ export async function streamChat(
     }
 
     if (!response.body) {
+      finishRequestTrace(activeRequestTrace, "error")
       onError(new Error("Response body is null"))
       return
     }
@@ -569,6 +700,7 @@ export async function streamChat(
     const recordReasoning = (line: string) => {
       const reasoningParts = extractReasoningTextFromLine(line)
       for (const part of reasoningParts) {
+        if (part) markFirstResponse(activeRequestTrace)
         reasoningTokensForwarded += part.length
         callbacks.onReasoningToken?.(part)
       }
@@ -589,11 +721,15 @@ export async function streamChat(
             recordReasoning(trimmed)
             const toolDelta = parseToolCallDeltaFromLine(trimmed)
             if (toolDelta) {
+              markFirstResponse(activeRequestTrace)
               toolCallDeltaCount += 1
               callbacks.onToolCallDelta?.(toolDelta)
             } else {
               const token = providerConfig.parseStream(trimmed)
-              if (token !== null) recordToken(token)
+              if (token !== null) {
+                if (token) markFirstResponse(activeRequestTrace)
+                recordToken(token)
+              }
             }
           }
           break
@@ -613,12 +749,16 @@ export async function streamChat(
           recordReasoning(trimmed)
           const toolDelta = parseToolCallDeltaFromLine(trimmed)
           if (toolDelta) {
+            markFirstResponse(activeRequestTrace)
             toolCallDeltaCount += 1
             callbacks.onToolCallDelta?.(toolDelta)
             continue
           }
           const token = providerConfig.parseStream(trimmed)
-          if (token !== null) recordToken(token)
+          if (token !== null) {
+            if (token) markFirstResponse(activeRequestTrace)
+            recordToken(token)
+          }
         }
       }
 
@@ -645,6 +785,7 @@ export async function streamChat(
         contentCharsEmitted === 0 &&
         reasoningCharsObserved >= REASONING_DIAGNOSTIC_THRESHOLD
       ) {
+        finishRequestTrace(activeRequestTrace, "error", streamUsage)
         onError(
           new Error(
             `模型只输出了 ${reasoningCharsObserved.toLocaleString()} 字符的思考内容,但没有输出正文。` +
@@ -661,23 +802,28 @@ export async function streamChat(
       // a silently half-finished response.
       const finalFinishReason: string | null = finishReason
       if (finalFinishReason && isTruncationFinishReason(finalFinishReason)) {
+        finishRequestTrace(activeRequestTrace, "error", streamUsage)
         onError(buildOutputTruncatedError(finalFinishReason))
         return
       }
 
+      finishRequestTrace(activeRequestTrace, "success", streamUsage)
       onDone()
     } catch (err) {
       if (err instanceof Error && (err.name === "AbortError" || (signal?.aborted))) {
+        finishRequestTrace(activeRequestTrace, "cancelled", streamUsage)
         onDone()
         return
       }
       if (isFetchNetworkError(err)) {
+        finishRequestTrace(activeRequestTrace, "network_error", streamUsage)
         // Stream reader threw a network error mid-response (connection
         // dropped, server closed early, network blip). Same message
         // regardless of whether the webview is WebKit or Chromium.
         onError(new Error("流式响应读取中断,请检查网络、代理或接口稳定性后重试。"))
         return
       }
+      finishRequestTrace(activeRequestTrace, "error", streamUsage)
       onError(err instanceof Error ? err : new Error(String(err)))
     } finally {
       reader.releaseLock()

+ 72 - 2
src/lib/llm-client.usage.spec.ts

@@ -12,12 +12,13 @@ import { normalizeUserLlmMaxOutputTokens } from "./llm-context-size"
 
 const mocks = vi.hoisted(() => ({
   fetch: vi.fn(),
+  isFetchNetworkError: vi.fn(() => false),
   streamClaudeCodeCli: vi.fn(),
 }))
 
 vi.mock("./tauri-fetch", () => ({
   getHttpFetch: vi.fn(async () => mocks.fetch),
-  isFetchNetworkError: vi.fn(() => false),
+  isFetchNetworkError: (...args: unknown[]) => mocks.isFetchNetworkError(...args),
 }))
 
 vi.mock("./local-cli-config", () => ({
@@ -40,6 +41,8 @@ const config: LlmConfig = {
 describe("streamChat usage", () => {
   beforeEach(() => {
     mocks.fetch.mockReset()
+    mocks.isFetchNetworkError.mockReset()
+    mocks.isFetchNetworkError.mockReturnValue(false)
     mocks.streamClaudeCodeCli.mockReset()
   })
 
@@ -60,10 +63,22 @@ describe("streamChat usage", () => {
     const onUsage = vi.fn()
     const onDone = vi.fn()
     const onError = vi.fn()
+    const onRequestTrace = vi.fn()
 
-    await streamChat(config, [{ role: "user", content: "测试" }], {
+    await streamChat(config, [
+      {
+        role: "system",
+        content: [
+          { type: "text", text: "固定规则" },
+          { type: "text", text: "项目稳定核心", cacheControl: true },
+          { type: "text", text: "动态任务" },
+        ],
+      },
+      { role: "user", content: "测试" },
+    ], {
       onToken: vi.fn(),
       onUsage,
+      onRequestTrace,
       onDone,
       onError,
     })
@@ -82,6 +97,16 @@ describe("streamChat usage", () => {
     })
     expect(onDone).toHaveBeenCalledOnce()
     expect(onError).not.toHaveBeenCalled()
+    expect(onRequestTrace).toHaveBeenCalledOnce()
+    expect(onRequestTrace).toHaveBeenCalledWith(expect.objectContaining({
+      provider: "openai",
+      model: "gpt-test",
+      prefixFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
+      inputTokens: 1200,
+      outputTokens: 80,
+      cacheReadTokens: 1024,
+      status: "success",
+    }))
   })
 
   it("同行 tool_calls 仍触发 onReasoningToken", async () => {
@@ -334,9 +359,11 @@ describe("streamChat usage", () => {
       ].join("\n"), { status: 200 }))
 
     const onError = vi.fn()
+    const onRequestTrace = vi.fn()
     await streamChat(config, [{ role: "user", content: "写第一章" }], {
       onToken: vi.fn(),
       onDone: vi.fn(),
+      onRequestTrace,
       onError,
     })
 
@@ -344,6 +371,7 @@ describe("streamChat usage", () => {
     const retryBody = JSON.parse(String((mocks.fetch.mock.calls[1][1] as RequestInit).body))
     expect(retryBody.max_tokens).toBe(8_192)
     expect(onError).not.toHaveBeenCalled()
+    expect(onRequestTrace.mock.calls.map(([trace]) => trace.status)).toEqual(["error", "success"])
   })
 
   it("脏 SSE 行不会中断整轮流式响应", async () => {
@@ -376,4 +404,46 @@ describe("streamChat usage", () => {
     expect(onDone).toHaveBeenCalledOnce()
     expect(onError).not.toHaveBeenCalled()
   })
+
+  it("records a mid-stream network failure as network_error", async () => {
+    const body = new ReadableStream<Uint8Array>({
+      start(controller) {
+        controller.error(new Error("connection dropped"))
+      },
+    })
+    mocks.fetch.mockResolvedValue(new Response(body, { status: 200 }))
+    mocks.isFetchNetworkError.mockReturnValue(true)
+    const onRequestTrace = vi.fn()
+    const onError = vi.fn()
+
+    await streamChat(config, [{ role: "user", content: "测试网络中断" }], {
+      onToken: vi.fn(),
+      onRequestTrace,
+      onDone: vi.fn(),
+      onError,
+    })
+
+    expect(onRequestTrace).toHaveBeenCalledWith(expect.objectContaining({ status: "network_error" }))
+    expect(onError).toHaveBeenCalledWith(expect.objectContaining({
+      message: expect.stringContaining("流式响应读取中断"),
+    }))
+  })
+
+  it("records an aborted supplier attempt as cancelled", async () => {
+    mocks.fetch.mockRejectedValue(new DOMException("aborted", "AbortError"))
+    const controller = new AbortController()
+    controller.abort()
+    const onRequestTrace = vi.fn()
+    const onDone = vi.fn()
+
+    await streamChat(config, [{ role: "user", content: "取消请求" }], {
+      onToken: vi.fn(),
+      onRequestTrace,
+      onDone,
+      onError: vi.fn(),
+    }, controller.signal)
+
+    expect(onRequestTrace).toHaveBeenCalledWith(expect.objectContaining({ status: "cancelled" }))
+    expect(onDone).toHaveBeenCalledOnce()
+  })
 })

+ 131 - 0
src/lib/llm-request-trace.spec.ts

@@ -0,0 +1,131 @@
+import { describe, expect, it } from "vitest"
+import type { ChatMessage, RequestOverrides } from "./llm-providers"
+import {
+  LlmRequestTraceCollector,
+  MAX_LLM_REQUEST_CACHE_TRACES,
+  buildLlmRequestPrefixDescriptor,
+  isLlmRequestCacheTrace,
+  type LlmRequestCacheTrace,
+} from "./llm-request-trace"
+import type { LlmConfig } from "@/stores/wiki-store"
+
+const config: LlmConfig = {
+  provider: "openai",
+  apiKey: "sk-must-not-be-persisted",
+  model: "gpt-test",
+  apiMode: "chat_completions",
+  ollamaUrl: "",
+  customEndpoint: "https://secret.example/v1",
+  maxContextSize: 204_800,
+  reasoning: { mode: "medium" },
+}
+
+function messages(dynamicRule: string, stableCore = "项目稳定核心"): ChatMessage[] {
+  return [
+    {
+      role: "system",
+      content: [
+        { type: "text", text: "固定基础规则\n" },
+        { type: "text", text: stableCore, cacheControl: true },
+        { type: "text", text: `\n动态规则:${dynamicRule}` },
+      ],
+    },
+    { role: "user", content: `任务:${dynamicRule}` },
+  ]
+}
+
+const tools: NonNullable<RequestOverrides["tools"]> = [{
+  type: "function",
+  function: {
+    name: "read_outline",
+    description: "读取大纲",
+    parameters: { type: "object", properties: {} },
+  },
+}]
+
+describe("LLM request prefix fingerprint", () => {
+  it("ignores task, chapter and Skill changes after the cache breakpoint", async () => {
+    const first = await buildLlmRequestPrefixDescriptor(config, messages("写第 11 章并启用 Skill A"), {
+      tools,
+      toolChoice: "auto",
+      reasoning: { mode: "medium" },
+    })
+    const second = await buildLlmRequestPrefixDescriptor(config, messages("分析第 229 章并启用 Skill B"), {
+      tools,
+      toolChoice: "auto",
+      reasoning: { mode: "medium" },
+    })
+
+    expect(first.prefixFingerprint).toMatch(/^[a-f0-9]{64}$/)
+    expect(second.prefixFingerprint).toBe(first.prefixFingerprint)
+    expect(first.prefixEstimatedTokens).toBeGreaterThan(0)
+  })
+
+  it("changes for stable text, model, tool schema and reasoning changes", async () => {
+    const base = await buildLlmRequestPrefixDescriptor(config, messages("动态"), {
+      tools,
+      toolChoice: "auto",
+      reasoning: { mode: "medium" },
+    })
+    const variants = await Promise.all([
+      buildLlmRequestPrefixDescriptor(config, messages("动态", "变化后的稳定核心"), { tools, toolChoice: "auto", reasoning: { mode: "medium" } }),
+      buildLlmRequestPrefixDescriptor({ ...config, model: "gpt-other" }, messages("动态"), { tools, toolChoice: "auto", reasoning: { mode: "medium" } }),
+      buildLlmRequestPrefixDescriptor(config, messages("动态"), { tools: [{ ...tools[0], function: { ...tools[0].function, description: "变化" } }], toolChoice: "auto", reasoning: { mode: "medium" } }),
+      buildLlmRequestPrefixDescriptor(config, messages("动态"), { tools, toolChoice: "auto", reasoning: { mode: "high" } }),
+    ])
+
+    for (const variant of variants) {
+      expect(variant.prefixFingerprint).not.toBe(base.prefixFingerprint)
+    }
+  })
+
+  it("returns no fingerprint when no virtual or real breakpoint exists", async () => {
+    await expect(buildLlmRequestPrefixDescriptor(config, [
+      { role: "system", content: "普通系统提示" },
+      { role: "user", content: "任务" },
+    ])).resolves.toEqual({})
+  })
+})
+
+function trace(index: number, fingerprint = "a".repeat(64)): LlmRequestCacheTrace {
+  return {
+    provider: "openai",
+    model: "gpt-test",
+    apiMode: "chat_completions",
+    prefixFingerprint: fingerprint,
+    startedAt: index * 1_000,
+    finishedAt: index * 1_000 + 400,
+    durationMs: 400,
+    firstResponseMs: 120,
+    inputTokens: 1_000,
+    outputTokens: 100,
+    cacheReadTokens: 800,
+    cacheWriteTokens: 0,
+    status: "success",
+  }
+}
+
+describe("LLM request trace collector", () => {
+  it("computes same-prefix start/idle gaps and caps snapshots at 32 requests", () => {
+    const collector = new LlmRequestTraceCollector()
+    for (let index = 0; index < MAX_LLM_REQUEST_CACHE_TRACES + 2; index += 1) {
+      collector.record(trace(index))
+    }
+
+    const snapshot = collector.snapshot()
+    expect(snapshot.requests).toHaveLength(MAX_LLM_REQUEST_CACHE_TRACES)
+    expect(snapshot.omittedRequestCount).toBe(2)
+    expect(snapshot.requests[0].startedAt).toBe(2_000)
+    expect(snapshot.requests[1]).toMatchObject({ startGapMs: 1_000, idleGapMs: 600 })
+  })
+
+  it("stores only sanitized diagnostics and strictly rejects damaged traces", () => {
+    const value = trace(1)
+    expect(isLlmRequestCacheTrace(value)).toBe(true)
+    expect(JSON.stringify(value)).not.toContain(config.apiKey)
+    expect(JSON.stringify(value)).not.toContain(config.customEndpoint)
+    expect(JSON.stringify(value)).not.toContain("项目稳定核心")
+    expect(isLlmRequestCacheTrace({ ...value, status: "timeout" })).toBe(false)
+    expect(isLlmRequestCacheTrace({ ...value, durationMs: -1 })).toBe(false)
+  })
+})

+ 248 - 0
src/lib/llm-request-trace.ts

@@ -0,0 +1,248 @@
+import { estimateChatMessagesTokens } from "@/lib/chat-request-budget"
+import { sha256Text } from "@/lib/context-hub/fingerprint"
+import type { ChatMessage, RequestOverrides } from "@/lib/llm-providers"
+import type { LlmUsage } from "@/lib/llm-usage"
+import type { LlmConfig } from "@/stores/wiki-store"
+
+export const MAX_LLM_REQUEST_CACHE_TRACES = 32
+const LLM_REQUEST_TRACE_PROVIDERS = new Set<LlmConfig["provider"]>([
+  "openai",
+  "anthropic",
+  "google",
+  "azure",
+  "ollama",
+  "custom",
+  "minimax",
+  "claude-code",
+  "codex-cli",
+  "cursor-cli",
+])
+
+export type LlmRequestTraceStatus = "success" | "error" | "cancelled" | "network_error"
+
+export interface LlmRequestCacheTrace {
+  provider: LlmConfig["provider"]
+  model: string
+  apiMode: string
+  prefixFingerprint?: string
+  prefixEstimatedTokens?: number
+  startedAt: number
+  finishedAt: number
+  durationMs: number
+  firstResponseMs?: number
+  startGapMs?: number
+  idleGapMs?: number
+  inputTokens?: number
+  outputTokens?: number
+  cacheReadTokens?: number
+  cacheWriteTokens?: number
+  status: LlmRequestTraceStatus
+}
+
+export function resolveLlmRequestApiMode(config: LlmConfig): string {
+  if (config.provider === "custom") return config.apiMode ?? "chat_completions"
+  if (config.provider === "anthropic" || config.provider === "minimax") return "anthropic_messages"
+  if (config.provider === "google") return "gemini_generate_content"
+  if (config.provider === "azure") return "azure_chat_completions"
+  if (config.provider === "claude-code") return "claude_code_cli"
+  if (config.provider === "codex-cli") return "codex_cli"
+  return "chat_completions"
+}
+
+export interface LlmRequestTraceSnapshot {
+  requests: LlmRequestCacheTrace[]
+  omittedRequestCount: number
+}
+
+function textBlocksThroughLastBreakpoint(messages: ChatMessage[]): ChatMessage[] | null {
+  let lastMessageIndex = -1
+  let lastBlockIndex = -1
+  for (const [messageIndex, message] of messages.entries()) {
+    if (!Array.isArray(message.content)) continue
+    for (const [blockIndex, block] of message.content.entries()) {
+      if (block.type === "text" && block.cacheControl) {
+        lastMessageIndex = messageIndex
+        lastBlockIndex = blockIndex
+      }
+    }
+  }
+  if (lastMessageIndex < 0) return null
+
+  return messages.slice(0, lastMessageIndex + 1).map((message, messageIndex) => {
+    if (messageIndex !== lastMessageIndex || !Array.isArray(message.content)) {
+      return message
+    }
+    return {
+      ...message,
+      content: message.content.slice(0, lastBlockIndex + 1),
+    }
+  })
+}
+
+function canonicalMessage(message: ChatMessage): unknown {
+  return {
+    role: message.role,
+    content: typeof message.content === "string"
+      ? message.content
+      : message.content.map((block) => block.type === "text"
+        ? { type: "text", text: block.text, cacheControl: Boolean(block.cacheControl) }
+        : { type: "image", mediaType: block.mediaType, dataBase64: block.dataBase64 }),
+    ...(message.tool_calls ? { tool_calls: message.tool_calls } : {}),
+    ...(message.tool_call_id ? { tool_call_id: message.tool_call_id } : {}),
+    ...(message.name ? { name: message.name } : {}),
+    ...(message.reasoning_content !== undefined ? { reasoning_content: message.reasoning_content } : {}),
+  }
+}
+
+export async function buildLlmRequestPrefixDescriptor(
+  config: LlmConfig,
+  messages: ChatMessage[],
+  overrides?: RequestOverrides,
+): Promise<{ prefixFingerprint?: string; prefixEstimatedTokens?: number }> {
+  const prefixMessages = textBlocksThroughLastBreakpoint(messages)
+  if (!prefixMessages) return {}
+
+  const canonical = JSON.stringify({
+    provider: config.provider,
+    model: config.model,
+    apiMode: resolveLlmRequestApiMode(config),
+    tools: overrides?.tools ?? [],
+    toolChoice: overrides?.toolChoice,
+    reasoning: overrides?.reasoning ?? config.reasoning ?? { mode: "auto" },
+    messages: prefixMessages.map(canonicalMessage),
+  })
+  return {
+    prefixFingerprint: await sha256Text(canonical),
+    prefixEstimatedTokens: estimateChatMessagesTokens(prefixMessages),
+  }
+}
+
+export function buildLlmRequestCacheTrace(input: {
+  config: LlmConfig
+  prefixFingerprint?: string
+  prefixEstimatedTokens?: number
+  startedAt: number
+  finishedAt: number
+  firstResponseAt?: number
+  usage?: LlmUsage
+  status: LlmRequestTraceStatus
+}): LlmRequestCacheTrace {
+  return {
+    provider: input.config.provider,
+    model: input.config.model,
+    apiMode: resolveLlmRequestApiMode(input.config),
+    ...(input.prefixFingerprint ? { prefixFingerprint: input.prefixFingerprint } : {}),
+    ...(input.prefixEstimatedTokens !== undefined
+      ? { prefixEstimatedTokens: input.prefixEstimatedTokens }
+      : {}),
+    startedAt: input.startedAt,
+    finishedAt: input.finishedAt,
+    durationMs: Math.max(0, input.finishedAt - input.startedAt),
+    ...(input.firstResponseAt !== undefined
+      ? { firstResponseMs: Math.max(0, input.firstResponseAt - input.startedAt) }
+      : {}),
+    ...(input.usage?.inputTokens !== undefined ? { inputTokens: input.usage.inputTokens } : {}),
+    ...(input.usage?.outputTokens !== undefined ? { outputTokens: input.usage.outputTokens } : {}),
+    ...(input.usage?.cachedInputTokens !== undefined
+      ? { cacheReadTokens: input.usage.cachedInputTokens }
+      : {}),
+    ...(input.usage?.cacheWriteInputTokens !== undefined
+      ? { cacheWriteTokens: input.usage.cacheWriteInputTokens }
+      : {}),
+    status: input.status,
+  }
+}
+
+function requestKey(trace: LlmRequestCacheTrace): string | null {
+  if (!trace.prefixFingerprint) return null
+  return [trace.provider, trace.model, trace.apiMode, trace.prefixFingerprint].join("\u0000")
+}
+
+export class LlmRequestTraceCollector {
+  private traces: LlmRequestCacheTrace[] = []
+  private omitted = 0
+
+  record = (trace: LlmRequestCacheTrace): void => {
+    this.traces.push(copyLlmRequestCacheTrace(trace))
+    this.traces.sort((left, right) => left.startedAt - right.startedAt)
+    if (this.traces.length > MAX_LLM_REQUEST_CACHE_TRACES) {
+      const overflow = this.traces.length - MAX_LLM_REQUEST_CACHE_TRACES
+      this.traces.splice(0, overflow)
+      this.omitted += overflow
+    }
+  }
+
+  snapshot(): LlmRequestTraceSnapshot {
+    const previousByKey = new Map<string, LlmRequestCacheTrace>()
+    const requests = this.traces.map((source) => {
+      const trace = { ...source }
+      const key = requestKey(trace)
+      if (key) {
+        const previous = previousByKey.get(key)
+        if (previous) {
+          trace.startGapMs = Math.max(0, trace.startedAt - previous.startedAt)
+          trace.idleGapMs = Math.max(0, trace.startedAt - previous.finishedAt)
+        }
+        previousByKey.set(key, trace)
+      }
+      return trace
+    })
+    return { requests, omittedRequestCount: this.omitted }
+  }
+}
+
+export function copyLlmRequestCacheTrace(trace: LlmRequestCacheTrace): LlmRequestCacheTrace {
+  return {
+    provider: trace.provider,
+    model: trace.model,
+    apiMode: trace.apiMode,
+    ...(trace.prefixFingerprint !== undefined ? { prefixFingerprint: trace.prefixFingerprint } : {}),
+    ...(trace.prefixEstimatedTokens !== undefined ? { prefixEstimatedTokens: trace.prefixEstimatedTokens } : {}),
+    startedAt: trace.startedAt,
+    finishedAt: trace.finishedAt,
+    durationMs: trace.durationMs,
+    ...(trace.firstResponseMs !== undefined ? { firstResponseMs: trace.firstResponseMs } : {}),
+    ...(trace.startGapMs !== undefined ? { startGapMs: trace.startGapMs } : {}),
+    ...(trace.idleGapMs !== undefined ? { idleGapMs: trace.idleGapMs } : {}),
+    ...(trace.inputTokens !== undefined ? { inputTokens: trace.inputTokens } : {}),
+    ...(trace.outputTokens !== undefined ? { outputTokens: trace.outputTokens } : {}),
+    ...(trace.cacheReadTokens !== undefined ? { cacheReadTokens: trace.cacheReadTokens } : {}),
+    ...(trace.cacheWriteTokens !== undefined ? { cacheWriteTokens: trace.cacheWriteTokens } : {}),
+    status: trace.status,
+  }
+}
+
+export function isLlmRequestCacheTrace(value: unknown): value is LlmRequestCacheTrace {
+  if (!value || typeof value !== "object") return false
+  const source = value as Record<string, unknown>
+  const optionalNumber = (key: string) => source[key] === undefined
+    || (typeof source[key] === "number" && Number.isFinite(source[key]) && source[key] >= 0)
+  return typeof source.provider === "string"
+    && LLM_REQUEST_TRACE_PROVIDERS.has(source.provider as LlmConfig["provider"])
+    && typeof source.model === "string"
+    && typeof source.apiMode === "string"
+    && source.apiMode.length > 0
+    && (source.prefixFingerprint === undefined
+      || (typeof source.prefixFingerprint === "string" && /^[a-f0-9]{64}$/.test(source.prefixFingerprint)))
+    && typeof source.startedAt === "number"
+    && Number.isFinite(source.startedAt)
+    && source.startedAt >= 0
+    && typeof source.finishedAt === "number"
+    && Number.isFinite(source.finishedAt)
+    && source.finishedAt >= source.startedAt
+    && typeof source.durationMs === "number"
+    && Number.isFinite(source.durationMs)
+    && source.durationMs >= 0
+    && optionalNumber("prefixEstimatedTokens")
+    && optionalNumber("firstResponseMs")
+    && optionalNumber("startGapMs")
+    && optionalNumber("idleGapMs")
+    && optionalNumber("inputTokens")
+    && optionalNumber("outputTokens")
+    && optionalNumber("cacheReadTokens")
+    && optionalNumber("cacheWriteTokens")
+    && (source.status === "success"
+      || source.status === "error"
+      || source.status === "cancelled"
+      || source.status === "network_error")
+}

+ 3 - 1
src/lib/novel/chapter-execution-contract.ts

@@ -1,4 +1,4 @@
-import { streamChat } from "@/lib/llm-client"
+import { streamChat, type StreamCallbacks } from "@/lib/llm-client"
 import type { LlmConfig } from "@/stores/wiki-store"
 
 export interface ChapterExecutionSceneStep {
@@ -99,6 +99,7 @@ export async function runChapterExecutionContractBuild(
   llmConfig: LlmConfig,
   planContent: string,
   signal?: AbortSignal,
+  onRequestTrace?: StreamCallbacks["onRequestTrace"],
 ): Promise<ChapterExecutionContract> {
   if (!planContent.trim()) return createEmptyContract()
 
@@ -116,6 +117,7 @@ export async function runChapterExecutionContractBuild(
       onError: (error: Error) => {
         streamError = error
       },
+      onRequestTrace,
     },
     signal,
   )

+ 3 - 1
src/lib/novel/chapter-execution-report.ts

@@ -1,4 +1,4 @@
-import { streamChat } from "@/lib/llm-client"
+import { streamChat, type StreamCallbacks } from "@/lib/llm-client"
 import type { LlmConfig } from "@/stores/wiki-store"
 import type { ChapterExecutionContract } from "./chapter-execution-contract"
 import { contractToTaskBriefText } from "./chapter-execution-contract"
@@ -51,6 +51,7 @@ export async function runChapterExecutionReportCheck(
   contract: ChapterExecutionContract,
   finalContent: string,
   signal?: AbortSignal,
+  onRequestTrace?: StreamCallbacks["onRequestTrace"],
 ): Promise<ChapterExecutionReport> {
   let responseText = ""
   let streamError: Error | null = null
@@ -66,6 +67,7 @@ export async function runChapterExecutionReportCheck(
       onError: (error: Error) => {
         streamError = error
       },
+      onRequestTrace,
     },
     signal,
   )

+ 5 - 1
src/lib/novel/chapter-plan-compliance.ts

@@ -1,4 +1,4 @@
-import { streamChat } from "@/lib/llm-client"
+import { streamChat, type StreamCallbacks } from "@/lib/llm-client"
 import type { LlmConfig } from "@/stores/wiki-store"
 import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "./chapter-excerpts"
 
@@ -61,6 +61,7 @@ export async function runChapterPlanComplianceCheck(
   planBlueprint: string,
   finalContent: string,
   signal?: AbortSignal,
+  onRequestTrace?: StreamCallbacks["onRequestTrace"],
 ): Promise<string> {
   if (!planBlueprint.trim()) return ""
   if (!finalContent.trim()) return ""
@@ -74,6 +75,7 @@ export async function runChapterPlanComplianceCheck(
       onToken: (token) => { result += token },
       onDone: () => {},
       onError: (error) => { streamError = error },
+      onRequestTrace,
     },
     signal,
   )
@@ -156,6 +158,7 @@ export async function runChapterPlanDeviationRepair(
   finalContent: string,
   complianceResult: ParsedChapterPlanComplianceResult | string,
   signal?: AbortSignal,
+  onRequestTrace?: StreamCallbacks["onRequestTrace"],
 ): Promise<string> {
   if (!planBlueprint.trim()) return finalContent.trim()
   if (!finalContent.trim()) return ""
@@ -169,6 +172,7 @@ export async function runChapterPlanDeviationRepair(
       onToken: (token) => { result += token },
       onDone: () => {},
       onError: (error) => { streamError = error },
+      onRequestTrace,
     },
     signal,
   )

+ 41 - 0
src/lib/novel/deep-chapter-generation.spec.ts

@@ -156,6 +156,47 @@ function createLegacyPlanComplianceDeps(reviewResults: NovelReviewResult[] = [])
 }
 
 describe("runDeepChapterGeneration", () => {
+  it("forwards every internal model request trace to one workflow collector", async () => {
+    const deps = createDeps()
+    const onRequestTrace = vi.fn()
+    let index = 0
+    vi.mocked(deps.streamChat).mockImplementation(async (
+      _config: LlmConfig,
+      messages: ChatMessage[],
+      callbacks: StreamCallbacks,
+    ) => {
+      index += 1
+      callbacks.onRequestTrace?.({
+        provider: "openai",
+        model: "test-model",
+        apiMode: "chat_completions",
+        prefixFingerprint: "stable",
+        startedAt: index * 100,
+        finishedAt: index * 100 + 50,
+        durationMs: 50,
+        status: "success",
+      })
+      const prompt = messagesPromptText(messages)
+      callbacks.onToken(prompt.includes("正文") ? chapterText("正文") : "写作任务书")
+      callbacks.onDone()
+    })
+
+    await runDeepChapterGeneration(
+      {
+        projectPath: "E:/Novel",
+        userRequest: "生成第三章",
+        chapterNumber: 3,
+        llmConfig,
+        aiWorkflowMode: "standard",
+      },
+      { onRequestTrace },
+      deps,
+    )
+
+    expect(onRequestTrace).toHaveBeenCalledTimes(vi.mocked(deps.streamChat).mock.calls.length)
+    expect(onRequestTrace).toHaveBeenCalled()
+  })
+
   it("routes workflow, prose, and de-AI stages to their configured models", async () => {
     const previousState = useWikiStore.getState()
     useWikiStore.setState({

+ 72 - 24
src/lib/novel/deep-chapter-generation.ts

@@ -8,6 +8,7 @@ import {
 import { useWikiStore } from "@/stores/wiki-store";
 import type { AiWorkflowMode } from "@/lib/agent/workflow-mode";
 import type { AgentActivityEvent, AgentActivityKind } from "@/lib/agent/types";
+import type { LlmRequestCacheTrace } from "@/lib/llm-request-trace";
 import {
   isReasoningDisabled,
   isReasoningOnlyResponseError,
@@ -87,6 +88,7 @@ export interface DeepChapterGenerationCallbacks {
   onCheckpoint?: (checkpoint: DeepChapterGenerationResumeCheckpoint) => void;
   onWorkflowEvent?: (event: ChapterWorkflowEvent) => void;
   onActivityEvent?: (event: AgentActivityEvent) => void;
+  onRequestTrace?: (trace: LlmRequestCacheTrace) => void;
 }
 
 export interface DeepChapterGenerationResult {
@@ -493,7 +495,9 @@ export async function runDeepChapterGeneration(
   if (!executionContract && workflowProfile.runExecutionContractBuild && planBlueprint) {
     try {
       const buildContract = deps.runChapterExecutionContractBuild || runChapterExecutionContractBuild;
-      executionContract = await buildContract(workflowConfig, planBlueprint, signal);
+      executionContract = callbacks.onRequestTrace
+        ? await buildContract(workflowConfig, planBlueprint, signal, callbacks.onRequestTrace)
+        : await buildContract(workflowConfig, planBlueprint, signal);
     } catch (error) {
       rethrowIfUserAbort(error, signal);
       console.warn("[Deep Chapter] 执行清单生成失败,使用本地兜底解析:", error);
@@ -834,6 +838,7 @@ export async function runDeepChapterGeneration(
             ),
           analysisRequestOverrides,
           cachePrefix,
+          callbacks.onRequestTrace,
         ),
       (value) => `写作任务书完成,约 ${countChapterChars(value)} 字。`,
       (value) => ({ chars: countChapterChars(value) }),
@@ -908,6 +913,7 @@ export async function runDeepChapterGeneration(
             ),
           generationRequestOverrides,
           cachePrefix,
+          callbacks.onRequestTrace,
         ),
       (value) => {
         const chars = countChapterChars(value);
@@ -953,6 +959,7 @@ export async function runDeepChapterGeneration(
               ),
             generationRequestOverrides,
             cachePrefix,
+            callbacks.onRequestTrace,
           );
           const expandedChars = countChapterChars(expanded);
           if (expandedChars < lengthSpec.minChars) {
@@ -1105,6 +1112,7 @@ export async function runDeepChapterGeneration(
                 contextPack,
                 planBlueprint: planExecutionSummary,
                 throwOnFailure: true,
+                onRequestTrace: callbacks.onRequestTrace,
               },
               signal,
             )
@@ -1117,6 +1125,7 @@ export async function runDeepChapterGeneration(
                 contextPack,
                 planBlueprint: planExecutionSummary,
                 throwOnFailure: true,
+                onRequestTrace: callbacks.onRequestTrace,
               },
             );
       } catch (err) {
@@ -1258,6 +1267,7 @@ export async function runDeepChapterGeneration(
             ),
           generationRequestOverrides,
           cachePrefix,
+          callbacks.onRequestTrace,
         ),
       (value) =>
         `检测到 ${blockingIssues.length} 个阻断问题,已自动返修一次。返修后正文约 ${countChapterChars(value)} 字。`,
@@ -1325,6 +1335,7 @@ export async function runDeepChapterGeneration(
               contextPack,
               characterOnly: true,
               throwOnFailure: true,
+              onRequestTrace: callbacks.onRequestTrace,
             },
             signal,
           )
@@ -1337,6 +1348,7 @@ export async function runDeepChapterGeneration(
               contextPack,
               characterOnly: true,
               throwOnFailure: true,
+              onRequestTrace: callbacks.onRequestTrace,
             },
           );
       const postBlockingIssues = (postRevisionResults || []).filter(
@@ -1454,7 +1466,9 @@ export async function runDeepChapterGeneration(
           detail: "按场景验收标准逐项检查最终正文。",
           params: workflowBaseParams,
         },
-        () => runReport(workflowConfig, executionContract!, finalContent, signal),
+        () => callbacks.onRequestTrace
+          ? runReport(workflowConfig, executionContract!, finalContent, signal, callbacks.onRequestTrace)
+          : runReport(workflowConfig, executionContract!, finalContent, signal),
         (value) => executionReportToToolSummary(value),
         (value) => ({
           status: value.status,
@@ -1481,13 +1495,22 @@ export async function runDeepChapterGeneration(
         startChapterWorkflowStep(callbacks, repairStep);
         let repairedContent = "";
         try {
-          repairedContent = await runRepair(
-            writingConfig,
-            contractToTaskBriefText(executionContract!),
-            finalContent,
-            repairItems.join("\n"),
-            signal,
-          );
+          repairedContent = callbacks.onRequestTrace
+            ? await runRepair(
+                writingConfig,
+                contractToTaskBriefText(executionContract!),
+                finalContent,
+                repairItems.join("\n"),
+                signal,
+                callbacks.onRequestTrace,
+              )
+            : await runRepair(
+                writingConfig,
+                contractToTaskBriefText(executionContract!),
+                finalContent,
+                repairItems.join("\n"),
+                signal,
+              );
         } catch (error) {
           errorChapterWorkflowStep(callbacks, repairStep, error);
           throw error;
@@ -1507,7 +1530,9 @@ export async function runDeepChapterGeneration(
                 detail: "返修后再次检查执行清单失败项是否消除。",
                 params: workflowBaseParams,
               },
-              () => runReport(workflowConfig, executionContract!, repairedCandidate, signal),
+              () => callbacks.onRequestTrace
+                ? runReport(workflowConfig, executionContract!, repairedCandidate, signal, callbacks.onRequestTrace)
+                : runReport(workflowConfig, executionContract!, repairedCandidate, signal),
               (value) => executionReportToToolSummary(value),
               (value) => ({
                 status: value.status,
@@ -1575,7 +1600,9 @@ export async function runDeepChapterGeneration(
       planCompliance = await runChapterWorkflowStep(
         callbacks,
         complianceStep,
-        () => runCompliance(workflowConfig, planExecutionSummary, finalContent, signal),
+        () => callbacks.onRequestTrace
+          ? runCompliance(workflowConfig, planExecutionSummary, finalContent, signal, callbacks.onRequestTrace)
+          : runCompliance(workflowConfig, planExecutionSummary, finalContent, signal),
         (value) => value ? "计划履约度检查完成。" : "计划履约度检查完成,未返回具体结果。",
         (value) => ({ hasComplianceResult: Boolean(value?.trim()) }),
       );
@@ -1607,13 +1634,22 @@ export async function runDeepChapterGeneration(
           const complianceBeforeRepair = planCompliance;
           let repairedContent = "";
           try {
-            repairedContent = await runRepair(
-              writingConfig,
-              planExecutionSummary,
-              finalContent,
-              complianceBeforeRepair,
-              signal,
-            );
+            repairedContent = callbacks.onRequestTrace
+              ? await runRepair(
+                  writingConfig,
+                  planExecutionSummary,
+                  finalContent,
+                  complianceBeforeRepair,
+                  signal,
+                  callbacks.onRequestTrace,
+                )
+              : await runRepair(
+                  writingConfig,
+                  planExecutionSummary,
+                  finalContent,
+                  complianceBeforeRepair,
+                  signal,
+                );
           } catch (error) {
             errorChapterWorkflowStep(callbacks, repairStep, error);
             throw error;
@@ -1633,12 +1669,20 @@ export async function runDeepChapterGeneration(
               recheckResult = await runChapterWorkflowStep(
                 callbacks,
                 recheckStep,
-                () => runCompliance(
-                  workflowConfig,
-                  planExecutionSummary,
-                  repairedCandidate,
-                  signal,
-                ),
+                () => callbacks.onRequestTrace
+                  ? runCompliance(
+                      workflowConfig,
+                      planExecutionSummary,
+                      repairedCandidate,
+                      signal,
+                      callbacks.onRequestTrace,
+                    )
+                  : runCompliance(
+                      workflowConfig,
+                      planExecutionSummary,
+                      repairedCandidate,
+                      signal,
+                    ),
                 (value) => value ? "计划返修复检完成。" : "计划返修复检未返回具体结果。",
                 (value) => ({ hasComplianceResult: Boolean(value?.trim()) }),
               );
@@ -1790,6 +1834,7 @@ async function finalPolishChapter(
       ),
     requestOverrides,
     cachePrefix,
+    callbacks.onRequestTrace,
   );
   assertNotAborted(signal);
   return polished.trim() ? polished : currentContent;
@@ -1845,6 +1890,7 @@ async function collectModelText(
   onUpdate?: (content: string) => void,
   requestOverrides?: RequestOverrides,
   cachePrefix?: string,
+  onRequestTrace?: StreamCallbacks["onRequestTrace"],
 ): Promise<string> {
   let content = "";
   let reasoningBuffer = "";
@@ -1893,6 +1939,7 @@ async function collectModelText(
     onError: (error) => {
       streamError = error;
     },
+    onRequestTrace,
   };
 
   const streamOnce = async (effectiveOverrides?: RequestOverrides) => {
@@ -2298,6 +2345,7 @@ async function maybeInjectWritingEntityWebSearch(args: {
       llmConfig: args.workflowConfig,
       searchApiConfig: useWikiStore.getState().searchApiConfig,
       signal: args.signal,
+      onRequestTrace: args.callbacks.onRequestTrace,
     });
     if (result.searchedNames.length > 0 || result.markdown.trim()) {
       emitDeepChapterActivity(args.callbacks, {

+ 2 - 0
src/lib/novel/review-adapter.ts

@@ -20,6 +20,7 @@ export interface NovelReviewResult {
 
 export interface NovelReviewCallbacks {
   onThinking?: (content: string) => void
+  onRequestTrace?: StreamCallbacks["onRequestTrace"]
 }
 
 export interface ReviewChapterOptions extends NovelReviewCallbacks {
@@ -418,6 +419,7 @@ async function runReviewStage(
     onError: (error: Error) => {
       console.error("[Novel Review] Stream error:", error)
     },
+    onRequestTrace: callbacks.onRequestTrace,
   }
 
   const timeoutController = new AbortController()

+ 2 - 0
src/lib/novel/writing-entity-web-search.ts

@@ -36,6 +36,7 @@ export interface CollectWritingEntityWebSearchInput {
   llmConfig: LlmConfig
   searchApiConfig?: SearchApiConfig | null
   signal?: AbortSignal
+  onRequestTrace?: StreamCallbacks["onRequestTrace"]
   listEntityNames?: typeof listLocalEntityNames
   readPreviousBodies?: typeof readPreviousChapterBodies
   search?: typeof webSearch
@@ -315,6 +316,7 @@ async function completeText(
       onToken: (token) => { result += token },
       onDone: () => {},
       onError: () => {},
+      onRequestTrace: input.onRequestTrace,
     },
     input.signal,
   )