Просмотр исходного кода

Merge pull request #174 from SttFang/fix/thinking-model-temperature-clamp

fix(llm): clamp temperature to 1 for thinking models (kimi-k2.5 etc.)
Junxian Ma 7 месяцев назад
Родитель
Сommit
e1271d1282
2 измененных файлов с 159 добавлено и 4 удалено
  1. 118 2
      packages/core/src/__tests__/provider.test.ts
  2. 41 2
      packages/core/src/llm/provider.ts

+ 118 - 2
packages/core/src/__tests__/provider.test.ts

@@ -1,6 +1,10 @@
-import { describe, expect, it, vi } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
 import type OpenAI from "openai";
-import { chatCompletion, type LLMClient } from "../llm/provider.js";
+import {
+  __resetFixedTemperatureWarnings,
+  chatCompletion,
+  type LLMClient,
+} from "../llm/provider.js";
 
 const ZERO_USAGE = {
   prompt_tokens: 11,
@@ -134,3 +138,115 @@ describe("chatCompletion stream fallback", () => {
     expect(error.message).not.toContain("\"stream\": false");
   });
 });
+
+describe("chatCompletion fixed-temperature clamp (thinking models)", () => {
+  beforeEach(() => {
+    __resetFixedTemperatureWarnings();
+  });
+
+  function makeSyncClient(create: ReturnType<typeof vi.fn>, temperature: number): LLMClient {
+    return {
+      provider: "openai",
+      apiFormat: "chat",
+      stream: false,
+      _openai: {
+        chat: { completions: { create } },
+      } as unknown as OpenAI,
+      defaults: {
+        temperature,
+        maxTokens: 512,
+        thinkingBudget: 0,
+        maxTokensCap: null,
+        extra: {},
+      },
+    };
+  }
+
+  const OK_RESPONSE = {
+    choices: [{ message: { content: "ok" } }],
+    usage: ZERO_USAGE,
+  };
+
+  it("forces temperature=1 for kimi-k2.5 even when client default is 0.7", async () => {
+    const create = vi.fn().mockResolvedValue(OK_RESPONSE);
+    const client = makeSyncClient(create, 0.7);
+    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+    await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "hi" }]);
+
+    expect(create).toHaveBeenCalledTimes(1);
+    expect(create.mock.calls[0]?.[0]).toMatchObject({ temperature: 1 });
+    expect(warn).toHaveBeenCalledOnce();
+    expect(warn.mock.calls[0]?.[0]).toContain("kimi-k2.5");
+    warn.mockRestore();
+  });
+
+  it("clamps per-call temperature override (0.3) to 1 for kimi-k2.5", async () => {
+    const create = vi.fn().mockResolvedValue(OK_RESPONSE);
+    const client = makeSyncClient(create, 0.7);
+    vi.spyOn(console, "warn").mockImplementation(() => {});
+
+    await chatCompletion(
+      client,
+      "kimi-k2.5",
+      [{ role: "user", content: "hi" }],
+      { temperature: 0.3 },
+    );
+
+    expect(create.mock.calls[0]?.[0]).toMatchObject({ temperature: 1 });
+  });
+
+  it("only warns once per model name across multiple calls", async () => {
+    const create = vi.fn().mockResolvedValue(OK_RESPONSE);
+    const client = makeSyncClient(create, 0.7);
+    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+    await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "a" }]);
+    await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "b" }]);
+    await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "c" }]);
+
+    expect(warn).toHaveBeenCalledOnce();
+    warn.mockRestore();
+  });
+
+  it("also clamps any model name containing 'thinking'", async () => {
+    const create = vi.fn().mockResolvedValue(OK_RESPONSE);
+    const client = makeSyncClient(create, 0.5);
+    vi.spyOn(console, "warn").mockImplementation(() => {});
+
+    await chatCompletion(client, "kimi-thinking-preview", [
+      { role: "user", content: "hi" },
+    ]);
+
+    expect(create.mock.calls[0]?.[0]).toMatchObject({ temperature: 1 });
+  });
+
+  it("leaves regular models untouched (no clamp, no warning)", async () => {
+    const create = vi.fn().mockResolvedValue(OK_RESPONSE);
+    const client = makeSyncClient(create, 0.7);
+    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+    await chatCompletion(
+      client,
+      "moonshot-v1-32k",
+      [{ role: "user", content: "hi" }],
+      { temperature: 0.3 },
+    );
+
+    expect(create.mock.calls[0]?.[0]).toMatchObject({ temperature: 0.3 });
+    expect(warn).not.toHaveBeenCalled();
+    warn.mockRestore();
+  });
+
+  it("does not warn when requested temperature is already 1", async () => {
+    const create = vi.fn().mockResolvedValue(OK_RESPONSE);
+    const client = makeSyncClient(create, 1);
+    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+    await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "hi" }]);
+
+    expect(create.mock.calls[0]?.[0]).toMatchObject({ temperature: 1 });
+    expect(warn).not.toHaveBeenCalled();
+    warn.mockRestore();
+  });
+});

+ 41 - 2
packages/core/src/llm/provider.ts

@@ -192,6 +192,39 @@ function stripReservedKeys(extra: Record<string, unknown>): Record<string, unkno
   return result;
 }
 
+// === Fixed-Temperature Model Clamp ===
+//
+// 部分 thinking 模型(如 Moonshot kimi-k2.5、kimi-thinking-preview)强制要求
+// temperature === 1,其他值会被 API 直接 400 拒绝。为让这类模型能和 inkos
+// 已有的 per-call 温度调参(0.1 validator → 0.8 architect brainstorm)共存,
+// 在 provider 层统一夹制:命中名单就把传入的 temperature 强制改成 1,并对
+// 每个模型名打一次 warning 提示用户。
+
+function requiresFixedTemperature(model: string): boolean {
+  const lower = model.toLowerCase();
+  // kimi-k2.5 及其子变体(k2.5-preview 等),以及任何名字里带 "thinking" 的模型
+  return lower.startsWith("kimi-k2.5") || lower.includes("thinking");
+}
+
+const warnedFixedTemperatureModels = new Set<string>();
+
+function clampTemperatureForModel(model: string, requested: number): number {
+  if (!requiresFixedTemperature(model)) return requested;
+  if (requested === 1) return 1;
+  if (!warnedFixedTemperatureModels.has(model)) {
+    warnedFixedTemperatureModels.add(model);
+    console.warn(
+      `[inkos] 模型 "${model}" 是 thinking 模型,强制 temperature=1(原请求值 ${requested})`,
+    );
+  }
+  return 1;
+}
+
+// 仅测试用:清空 warning 去重集合。
+export function __resetFixedTemperatureWarnings(): void {
+  warnedFixedTemperatureModels.clear();
+}
+
 // === Error Wrapping ===
 
 function wrapLLMError(error: unknown, context?: { readonly baseUrl?: string; readonly model?: string }): Error {
@@ -273,7 +306,10 @@ export async function chatCompletion(
   const perCallMax = options?.maxTokens ?? client.defaults.maxTokens;
   const cap = client.defaults.maxTokensCap;
   const resolved = {
-    temperature: options?.temperature ?? client.defaults.temperature,
+    temperature: clampTemperatureForModel(
+      model,
+      options?.temperature ?? client.defaults.temperature,
+    ),
     maxTokens: cap !== null ? Math.min(perCallMax, cap) : perCallMax,
     extra: client.defaults.extra,
   };
@@ -369,7 +405,10 @@ export async function chatWithTools(
 ): Promise<ChatWithToolsResult> {
   try {
     const resolved = {
-      temperature: options?.temperature ?? client.defaults.temperature,
+      temperature: clampTemperatureForModel(
+        model,
+        options?.temperature ?? client.defaults.temperature,
+      ),
       maxTokens: options?.maxTokens ?? client.defaults.maxTokens,
     };
     // Tool-calling always uses streaming (only used by agent loop, not by writer/auditor)