Преглед изворни кода

fix(core): use model card output budgets for agents

fanghanjun пре 6 месеци
родитељ
комит
52f6992c30

+ 29 - 0
packages/core/src/__tests__/agent-max-tokens-policy.test.ts

@@ -0,0 +1,29 @@
+import { readFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+const AGENT_FILES = [
+  "../agents/architect.ts",
+  "../agents/length-normalizer.ts",
+  "../agents/planner.ts",
+  "../agents/polisher.ts",
+  "../agents/reviser.ts",
+  "../agents/writer.ts",
+] as const;
+
+describe("creative agent maxTokens policy", () => {
+  it("lets modelCard defaults own generation output budgets", async () => {
+    const testDir = dirname(fileURLToPath(import.meta.url));
+    const offenders: string[] = [];
+
+    for (const relativePath of AGENT_FILES) {
+      const source = await readFile(join(testDir, relativePath), "utf-8");
+      if (/\bmaxTokens\s*:/.test(source)) {
+        offenders.push(relativePath);
+      }
+    }
+
+    expect(offenders).toEqual([]);
+  });
+});

+ 12 - 15
packages/core/src/__tests__/architect.test.ts

@@ -524,7 +524,7 @@ describe("ArchitectAgent", () => {
     await expect(agent.generateFoundation(book)).rejects.toThrow(/book_rules/i);
     await expect(agent.generateFoundation(book)).rejects.toThrow(/book_rules/i);
   });
   });
 
 
-  it("passes maxTokens 16384 when generating foundation", async () => {
+  it("uses modelCard output budget when generating foundation", async () => {
     const agent = new ArchitectAgent({
     const agent = new ArchitectAgent({
       client: {
       client: {
         provider: "openai",
         provider: "openai",
@@ -581,13 +581,12 @@ describe("ArchitectAgent", () => {
 
 
     await agent.generateFoundation(book);
     await agent.generateFoundation(book);
 
 
-    expect(chatSpy).toHaveBeenCalledWith(
-      expect.any(Array),
-      expect.objectContaining({ temperature: 0.8, maxTokens: 20480 }),
-    );
+    const options = chatSpy.mock.calls[0]?.[1] as { temperature?: number; maxTokens?: number } | undefined;
+    expect(options).toEqual(expect.objectContaining({ temperature: 0.8 }));
+    expect(options).not.toHaveProperty("maxTokens");
   });
   });
 
 
-  it("passes maxTokens 16384 when generating foundation from import", async () => {
+  it("uses modelCard output budget when generating foundation from import", async () => {
     const agent = new ArchitectAgent({
     const agent = new ArchitectAgent({
       client: {
       client: {
         provider: "openai",
         provider: "openai",
@@ -644,13 +643,12 @@ describe("ArchitectAgent", () => {
 
 
     await agent.generateFoundationFromImport(book, "第一章正文");
     await agent.generateFoundationFromImport(book, "第一章正文");
 
 
-    expect(chatSpy).toHaveBeenCalledWith(
-      expect.any(Array),
-      expect.objectContaining({ temperature: 0.5, maxTokens: 20480 }),
-    );
+    const options = chatSpy.mock.calls[0]?.[1] as { temperature?: number; maxTokens?: number } | undefined;
+    expect(options).toEqual(expect.objectContaining({ temperature: 0.5 }));
+    expect(options).not.toHaveProperty("maxTokens");
   });
   });
 
 
-  it("passes maxTokens 16384 when generating fanfic foundation", async () => {
+  it("uses modelCard output budget when generating fanfic foundation", async () => {
     const agent = new ArchitectAgent({
     const agent = new ArchitectAgent({
       client: {
       client: {
         provider: "openai",
         provider: "openai",
@@ -707,10 +705,9 @@ describe("ArchitectAgent", () => {
 
 
     await agent.generateFanficFoundation(book, "正典文本", "canon");
     await agent.generateFanficFoundation(book, "正典文本", "canon");
 
 
-    expect(chatSpy).toHaveBeenCalledWith(
-      expect.any(Array),
-      expect.objectContaining({ temperature: 0.7, maxTokens: 20480 }),
-    );
+    const options = chatSpy.mock.calls[0]?.[1] as { temperature?: number; maxTokens?: number } | undefined;
+    expect(options).toEqual(expect.objectContaining({ temperature: 0.7 }));
+    expect(options).not.toHaveProperty("maxTokens");
   });
   });
 
 
   // ---- Phase 5 段落式架构稿专项 ----
   // ---- Phase 5 段落式架构稿专项 ----

+ 18 - 0
packages/core/src/__tests__/planner.test.ts

@@ -143,6 +143,24 @@ describe("PlannerAgent.planChapter memo generation", () => {
     expect(result.memo.body).toContain("## 当前任务");
     expect(result.memo.body).toContain("## 当前任务");
   });
   });
 
 
+  it("does not hard-cap memo generation below the configured model output budget", async () => {
+    const chatSpy = vi.spyOn(llmProvider, "chatCompletion").mockResolvedValue({
+      content: validMemoRaw(1),
+      usage: ZERO_USAGE,
+    } as unknown as Awaited<ReturnType<typeof llmProvider.chatCompletion>>);
+
+    await makePlanner().planChapter({
+      book: makeBook(),
+      bookDir,
+      chapterNumber: 1,
+    });
+
+    const callArgs = chatSpy.mock.calls[0]!;
+    const options = callArgs[3] as { temperature?: number; maxTokens?: number } | undefined;
+    expect(options).toEqual(expect.objectContaining({ temperature: 0.7 }));
+    expect(options).not.toHaveProperty("maxTokens");
+  });
+
   it("retries when the first response is malformed and succeeds on retry", async () => {
   it("retries when the first response is malformed and succeeds on retry", async () => {
     const chatSpy = vi.spyOn(llmProvider, "chatCompletion")
     const chatSpy = vi.spyOn(llmProvider, "chatCompletion")
       .mockResolvedValueOnce({
       .mockResolvedValueOnce({

+ 9 - 4
packages/core/src/__tests__/provider.test.ts

@@ -1,4 +1,4 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
 import type { AssistantMessage, Model, Api } from "@mariozechner/pi-ai";
 import type { AssistantMessage, Model, Api } from "@mariozechner/pi-ai";
 import {
 import {
   __resetFixedTemperatureWarnings,
   __resetFixedTemperatureWarnings,
@@ -403,6 +403,10 @@ describe("chatCompletion fixed-temperature clamp (thinking models)", () => {
     mockStreamSimple.mockReturnValue(makeTextStream("ok"));
     mockStreamSimple.mockReturnValue(makeTextStream("ok"));
   });
   });
 
 
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
   it("forces temperature=1 for kimi-k2.5 even when client default is 0.7", async () => {
   it("forces temperature=1 for kimi-k2.5 even when client default is 0.7", async () => {
     const client = makeClient(0.7);
     const client = makeClient(0.7);
     const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
     const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -512,7 +516,7 @@ describe("createLLMClient per-call maxTokens not capped (v2.0.0)", () => {
 });
 });
 
 
 describe("createLLMClient with providers lookup", () => {
 describe("createLLMClient with providers lookup", () => {
-  it("anthropic + claude-sonnet-4-6 拿到 modelCard 的 maxOutput (64000),不是 8192 fallback", async () => {
+  it("anthropic + claude-sonnet-4-6 拿到 modelCard 的 maxOutput (64000),不是未知模型兜底", async () => {
     const { createLLMClient } = await import("../llm/provider.js");
     const { createLLMClient } = await import("../llm/provider.js");
     const { LLMConfigSchema } = await import("../models/project.js");
     const { LLMConfigSchema } = await import("../models/project.js");
     const client = createLLMClient(LLMConfigSchema.parse({
     const client = createLLMClient(LLMConfigSchema.parse({
@@ -541,7 +545,7 @@ describe("createLLMClient with providers lookup", () => {
     expect(client.defaults.maxTokens).toBe(4096);
     expect(client.defaults.maxTokens).toBe(4096);
   });
   });
 
 
-  it("未知 model 走保守默认 8192", async () => {
+  it("未知 model 走 8192 * 3 的写作兜底预算", async () => {
     const { createLLMClient } = await import("../llm/provider.js");
     const { createLLMClient } = await import("../llm/provider.js");
     const { LLMConfigSchema } = await import("../models/project.js");
     const { LLMConfigSchema } = await import("../models/project.js");
     const client = createLLMClient(LLMConfigSchema.parse({
     const client = createLLMClient(LLMConfigSchema.parse({
@@ -551,7 +555,8 @@ describe("createLLMClient with providers lookup", () => {
       apiKey: "test",
       apiKey: "test",
       baseUrl: "https://middleman.example/v1",
       baseUrl: "https://middleman.example/v1",
     }));
     }));
-    expect(client.defaults.maxTokens).toBe(8192);
+    expect(client.defaults.maxTokens).toBe(24_576);
+    expect(client._piModel?.maxTokens).toBe(24_576);
   });
   });
 
 
   it("config.maxTokens 命中 modelCard 后被覆盖(用户填 4000 还是用 modelCard 的 64000)", async () => {
   it("config.maxTokens 命中 modelCard 后被覆盖(用户填 4000 还是用 modelCard 的 64000)", async () => {

+ 3 - 3
packages/core/src/agents/architect.ts

@@ -149,7 +149,7 @@ export class ArchitectAgent extends BaseAgent {
     const response = await this.chat([
     const response = await this.chat([
       { role: "system", content: langPrefix + systemPrompt + revisePrompt },
       { role: "system", content: langPrefix + systemPrompt + revisePrompt },
       { role: "user", content: userMessage },
       { role: "user", content: userMessage },
-    ], { maxTokens: 20480, temperature: 0.8 });
+    ], { temperature: 0.8 });
 
 
     return this.parseSections(response.content, resolvedLanguage);
     return this.parseSections(response.content, resolvedLanguage);
   }
   }
@@ -974,7 +974,7 @@ ${continuationDirective}
     const response = await this.chat([
     const response = await this.chat([
       { role: "system", content: systemPrompt },
       { role: "system", content: systemPrompt },
       { role: "user", content: userMessage },
       { role: "user", content: userMessage },
-    ], { maxTokens: 20480, temperature: 0.5 });
+    ], { temperature: 0.5 });
 
 
     return this.parseSections(response.content, resolvedLanguage);
     return this.parseSections(response.content, resolvedLanguage);
   }
   }
@@ -1031,7 +1031,7 @@ ${genreBody}
         role: "user",
         role: "user",
         content: `请为标题为"${book.title}"的${fanficMode}模式同人小说生成基础设定。目标${book.targetChapters}章,每章${book.chapterWordCount}字。`,
         content: `请为标题为"${book.title}"的${fanficMode}模式同人小说生成基础设定。目标${book.targetChapters}章,每章${book.chapterWordCount}字。`,
       },
       },
-    ], { maxTokens: 20480, temperature: 0.7 });
+    ], { temperature: 0.7 });
 
 
     return this.parseSections(response.content, book.language ?? "zh");
     return this.parseSections(response.content, book.language ?? "zh");
   }
   }

+ 1 - 4
packages/core/src/agents/length-normalizer.ts

@@ -49,10 +49,7 @@ export class LengthNormalizerAgent extends BaseAgent {
         { role: "system", content: systemPrompt },
         { role: "system", content: systemPrompt },
         { role: "user", content: userPrompt },
         { role: "user", content: userPrompt },
       ],
       ],
-      {
-        temperature: 0.2,
-        maxTokens: Math.max(4096, Math.ceil(originalCount * 1.2)),
-      },
+      { temperature: 0.2 },
     );
     );
 
 
     const normalizedContent = this.sanitizeNormalizedContent(response.content, input.chapterContent);
     const normalizedContent = this.sanitizeNormalizedContent(response.content, input.chapterContent);

+ 1 - 1
packages/core/src/agents/planner.ts

@@ -243,7 +243,7 @@ export class PlannerAgent extends BaseAgent {
           { role: "system", content: systemPrompt },
           { role: "system", content: systemPrompt },
           { role: "user", content: currentUserMessage },
           { role: "user", content: currentUserMessage },
         ],
         ],
-        { temperature: 0.7, maxTokens: 2000 },
+        { temperature: 0.7 },
       );
       );
 
 
       try {
       try {

+ 1 - 1
packages/core/src/agents/polisher.ts

@@ -57,7 +57,7 @@ export class PolisherAgent extends BaseAgent {
         { role: "system", content: systemPrompt },
         { role: "system", content: systemPrompt },
         { role: "user", content: userPrompt },
         { role: "user", content: userPrompt },
       ],
       ],
-      { temperature: input.temperature ?? 0.4, maxTokens: 16384 },
+      { temperature: input.temperature ?? 0.4 },
     );
     );
 
 
     const raw = response.content.trim();
     const raw = response.content.trim();

+ 1 - 1
packages/core/src/agents/reviser.ts

@@ -281,7 +281,7 @@ ${chapterContent}`;
         { role: "system", content: systemPrompt },
         { role: "system", content: systemPrompt },
         { role: "user", content: userPrompt },
         { role: "user", content: userPrompt },
       ],
       ],
-      { temperature: 0.3, maxTokens: 16384 },
+      { temperature: 0.3 },
     );
     );
 
 
     const output = this.parseOutput(
     const output = this.parseOutput(

+ 2 - 8
packages/core/src/agents/writer.ts

@@ -260,15 +260,12 @@ export class WriterAgent extends BaseAgent {
       en: `Phase 1: creative writing for chapter ${chapterNumber}`,
       en: `Phase 1: creative writing for chapter ${chapterNumber}`,
     });
     });
 
 
-    // Scale maxTokens to chapter word count (Chinese ≈ 1.5 tokens/char)
-    const creativeMaxTokens = Math.max(8192, Math.ceil(targetWords * 2));
-
     const creativeResponse = await this.chat(
     const creativeResponse = await this.chat(
       [
       [
         { role: "system", content: creativeSystemPrompt },
         { role: "system", content: creativeSystemPrompt },
         { role: "user", content: creativeUserPrompt },
         { role: "user", content: creativeUserPrompt },
       ],
       ],
-      { maxTokens: creativeMaxTokens, temperature: creativeTemperature },
+      { temperature: creativeTemperature },
     );
     );
     const creativeUsage = creativeResponse.usage;
     const creativeUsage = creativeResponse.usage;
 
 
@@ -610,15 +607,12 @@ export class WriterAgent extends BaseAgent {
       validationFeedback: params.validationFeedback,
       validationFeedback: params.validationFeedback,
     });
     });
 
 
-    // Settler outputs all truth files — scale with content size
-    const settlerMaxTokens = Math.max(8192, Math.ceil(params.content.length * 0.8));
-
     const response = await this.chat(
     const response = await this.chat(
       [
       [
         { role: "system", content: settlerSystem },
         { role: "system", content: settlerSystem },
         { role: "user", content: settlerUser },
         { role: "user", content: settlerUser },
       ],
       ],
-      { maxTokens: settlerMaxTokens, temperature: 0.3 },
+      { temperature: 0.3 },
     );
     );
 
 
     let mergedSettlement: ReturnType<typeof parseSettlementOutput> & {
     let mergedSettlement: ReturnType<typeof parseSettlementOutput> & {

+ 4 - 3
packages/core/src/llm/provider.ts

@@ -31,6 +31,7 @@ export interface StreamProgress {
 export type OnStreamProgress = (progress: StreamProgress) => void;
 export type OnStreamProgress = (progress: StreamProgress) => void;
 
 
 const INKOS_USER_AGENT = "InkOS/1.3.5";
 const INKOS_USER_AGENT = "InkOS/1.3.5";
+const UNKNOWN_MODEL_FALLBACK_MAX_TOKENS = 8192 * 3;
 
 
 function mergeUserAgent(headers?: Record<string, string>): Record<string, string> {
 function mergeUserAgent(headers?: Record<string, string>): Record<string, string> {
   return { "User-Agent": INKOS_USER_AGENT, ...(headers ?? {}) };
   return { "User-Agent": INKOS_USER_AGENT, ...(headers ?? {}) };
@@ -104,7 +105,7 @@ export interface LLMClient {
     readonly temperature: number;
     readonly temperature: number;
     /**
     /**
      * Per-call fallback: 当 agent 调 chat() 不传 options.maxTokens 时用这个值。
      * Per-call fallback: 当 agent 调 chat() 不传 options.maxTokens 时用这个值。
-     * 来自 providers bank 的 modelCard.maxOutput(v2.0.0 起)。
+     * 命中模型卡时来自 providers bank 的 modelCard.maxOutput;未知模型走写作兜底预算。
      */
      */
     readonly maxTokens: number;
     readonly maxTokens: number;
     /**
     /**
@@ -150,7 +151,7 @@ export function createLLMClient(config: LLMConfig): LLMClient {
   const _earlyCard = lookupModel(config.service ?? "custom", config.model);
   const _earlyCard = lookupModel(config.service ?? "custom", config.model);
   const defaults = {
   const defaults = {
     temperature: config.temperature ?? 0.7,
     temperature: config.temperature ?? 0.7,
-    maxTokens: _earlyCard?.maxOutput ?? 8192,
+    maxTokens: _earlyCard?.maxOutput ?? UNKNOWN_MODEL_FALLBACK_MAX_TOKENS,
     thinkingBudget: config.thinkingBudget ?? 0,
     thinkingBudget: config.thinkingBudget ?? 0,
     extra: config.extra ?? {},
     extra: config.extra ?? {},
   };
   };
@@ -196,7 +197,7 @@ export function createLLMClient(config: LLMConfig): LLMClient {
     input: ["text"] as ("text" | "image")[],
     input: ["text"] as ("text" | "image")[],
     cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
     cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
     contextWindow: modelCard?.contextWindowTokens ?? 128_000,
     contextWindow: modelCard?.contextWindowTokens ?? 128_000,
-    maxTokens: modelCard?.maxOutput ?? 8192,
+    maxTokens: modelCard?.maxOutput ?? UNKNOWN_MODEL_FALLBACK_MAX_TOKENS,
     ...(extraHeaders ? { headers: extraHeaders } : {}),
     ...(extraHeaders ? { headers: extraHeaders } : {}),
     ...(compat ? { compat } : {}),
     ...(compat ? { compat } : {}),
   };
   };