From 18e206047911a74fc782eb5a582d77ff03603970 Mon Sep 17 00:00:00 2001 From: xbx787 <125176443+xbx787@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:52:33 +0800 Subject: [PATCH] fix: restore continuation for empty truncated output --- src/llm/core.ts | 33 ++++-- tests/llm-continuation.test.ts | 179 +++++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+), 8 deletions(-) create mode 100644 tests/llm-continuation.test.ts diff --git a/src/llm/core.ts b/src/llm/core.ts index 5b171bb..37b4537 100644 --- a/src/llm/core.ts +++ b/src/llm/core.ts @@ -634,17 +634,34 @@ export async function callLlmWithContinuation(plugin, system, user, options, opt let text = String(first.text || ""); let finishReason = first.finishReason; let continuations = 0; - while (isTruncatedFinishReason(finishReason) && continuations < maxContinuations && text.trim()) { + // 思考模式可能耗尽 max_tokens,返回截断状态却没有可见正文;先关闭思考模式重试一次, + // 避免空正文直接阻断续写。对未识别的端点,applyThinkingParam 会保持请求不变。 + const emptyTruncated = !text.trim() && isTruncatedFinishReason(finishReason); + const effOptions = emptyTruncated ? Object.assign({}, options, { thinkingMode: "fast" }) : options; + if (emptyTruncated) { + try { + const retry = await callLlmWithMeta(plugin, system, user, effOptions); + text = String(retry.text || ""); + finishReason = retry.finishReason; + } catch { /* 重试失败时保留原始结果,交给下方兜底续写 */ } + } + while (isTruncatedFinishReason(finishReason) && continuations < maxContinuations) { continuations++; - const messages = [ - { role: "system", content: system }, - { role: "user", content: user }, - { role: "assistant", content: text }, - { role: "user", content: "你上一条回复因长度上限被截断了。请直接从断点处继续输出剩余内容、无缝衔接,不要重复任何已输出的文字、不要重新开头、不要加任何前言或结束语,直接接着写。" }, - ]; + const messages = text.trim() + ? [ + { role: "system", content: system }, + { role: "user", content: user }, + { role: "assistant", content: text }, + { role: "user", content: "你上一条回复因长度上限被截断了。请直接从断点处继续输出剩余内容、无缝衔接,不要重复任何已输出的文字、不要重新开头、不要加任何前言或结束语,直接接着写。" }, + ] + : [ + { role: "system", content: system }, + { role: "user", content: user }, + { role: "user", content: "你上一次生成因思考或输出过长被截断,且没有产生任何可见内容。请忽略截断状态,直接完整回答原始任务,不要提及截断、不要加任何前言。" }, + ]; let data; try { - data = await requestLlmChatCompletion(plugin, messages, options); + data = await requestLlmChatCompletion(plugin, messages, effOptions); } catch { break; // 续写失败就用已有内容,不让整体失败 } diff --git a/tests/llm-continuation.test.ts b/tests/llm-continuation.test.ts new file mode 100644 index 0000000..2430621 --- /dev/null +++ b/tests/llm-continuation.test.ts @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("obsidian", () => ({ + requestUrl: vi.fn(), +})); + +import { callLlmWithContinuation } from "../src/llm/core"; + +function jsonResponse(content, finishReason) { + return { + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => ({ + choices: [{ message: { role: "assistant", content }, finish_reason: finishReason }], + }), + text: async () => "", + }; +} + +function errorResponse(status, message) { + return { + ok: false, + status, + headers: { get: () => null }, + text: async () => JSON.stringify({ error: { message } }), + }; +} + +function makePlugin() { + return { + settings: { + llmEndpoint: "https://api.deepseek.com/v1", + llmApiKey: "test-key", + llmModel: "model", + }, + addTaskMeter: vi.fn(), + }; +} + +describe("callLlmWithContinuation", () => { + let fetchMock; + let previousWindow; + + beforeEach(() => { + previousWindow = globalThis.window; + fetchMock = vi.fn(); + globalThis.window = { fetch: fetchMock, setTimeout, clearTimeout }; + }); + + afterEach(() => { + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + }); + + it("拼接有正文但被长度上限截断的后续输出", async () => { + const requestBodies = []; + fetchMock.mockImplementation(async (_url, init) => { + requestBodies.push(JSON.parse(init.body)); + return requestBodies.length === 1 + ? jsonResponse("正文", "length") + : jsonResponse("后半", "stop"); + }); + const optionReceivers = []; + const options = { + stream: false, + noRetry: true, + skipQueue: true, + get thinkingMode() { + optionReceivers.push(this); + return "reasoning"; + }, + }; + + const result = await callLlmWithContinuation(makePlugin(), "系统提示", "原始任务", options); + + expect(result).toEqual({ + text: "正文后半", + finishReason: "stop", + truncated: false, + continuations: 1, + }); + expect(requestBodies[1].messages[2]).toEqual({ role: "assistant", content: "正文" }); + expect(optionReceivers[1]).toBe(options); + }); + + it("空输出被截断时关闭思考模式重试原始任务", async () => { + const requestBodies = []; + fetchMock.mockImplementation(async (_url, init) => { + requestBodies.push(JSON.parse(init.body)); + return requestBodies.length === 1 + ? jsonResponse("", "length") + : jsonResponse("完整正文", "stop"); + }); + + const result = await callLlmWithContinuation(makePlugin(), "系统提示", "原始任务", { + stream: false, + noRetry: true, + skipQueue: true, + thinkingMode: "reasoning", + }); + + expect(result).toEqual({ + text: "完整正文", + finishReason: "stop", + truncated: false, + continuations: 0, + }); + expect(requestBodies[1].messages).toEqual(requestBodies[0].messages); + expect(requestBodies[1].thinking).toEqual({ type: "disabled" }); + }); + + it("fast 重试仍被截断时继续拼接已获得的正文", async () => { + const requestBodies = []; + fetchMock.mockImplementation(async (_url, init) => { + requestBodies.push(JSON.parse(init.body)); + if (requestBodies.length === 1) return jsonResponse("", "length"); + if (requestBodies.length === 2) return jsonResponse("部分", "length"); + return jsonResponse("后续", "stop"); + }); + + const result = await callLlmWithContinuation(makePlugin(), "系统提示", "原始任务", { + stream: false, + noRetry: true, + skipQueue: true, + thinkingMode: "reasoning", + }); + + expect(result).toEqual({ + text: "部分后续", + finishReason: "stop", + truncated: false, + continuations: 1, + }); + expect(requestBodies[2].thinking).toEqual({ type: "disabled" }); + expect(requestBodies[2].messages[2]).toEqual({ role: "assistant", content: "部分" }); + }); + + it("fast 重试失败时使用没有 assistant 预填的完整回答兜底", async () => { + const requestBodies = []; + fetchMock.mockImplementation(async (_url, init) => { + requestBodies.push(JSON.parse(init.body)); + if (requestBodies.length === 1) return jsonResponse("", "length"); + if (requestBodies.length === 2) return errorResponse(400, "retry failed"); + return jsonResponse("完整回答", "stop"); + }); + + const result = await callLlmWithContinuation(makePlugin(), "系统提示", "原始任务", { + stream: false, + noRetry: true, + skipQueue: true, + thinkingMode: "reasoning", + }); + + expect(result.text).toBe("完整回答"); + expect(result.continuations).toBe(1); + expect(requestBodies[2].messages.some((message) => message.role === "assistant")).toBe(false); + expect(requestBodies[2].messages[2].content).toContain("请忽略截断状态,直接完整回答原始任务"); + }); + + it("空响应始终被截断时仍在有界次数内终止", async () => { + let requestCount = 0; + fetchMock.mockImplementation(async () => { + requestCount++; + return jsonResponse("", "length"); + }); + const maxContinuations = 2; + + const result = await callLlmWithContinuation(makePlugin(), "系统提示", "原始任务", { + stream: false, + noRetry: true, + skipQueue: true, + thinkingMode: "reasoning", + }, { maxContinuations }); + + expect(result.continuations).toBeLessThanOrEqual(maxContinuations); + expect(requestCount).toBeLessThanOrEqual(2 + maxContinuations); + }); +});