From 24bbb73c0600b09545dff8710d058b148fd4aa8c Mon Sep 17 00:00:00 2001 From: Tiantianr Date: Sun, 2 Aug 2026 05:28:02 +0800 Subject: [PATCH] fix(web): add prompt cache affinity to canvas agent (#74) Co-authored-by: Tiantianr <260026737+Tiantianr@users.noreply.github.com> --- .../canvas/canvas-assistant-panel.tsx | 5 ++-- web/src/lib/openai-prompt-cache.ts | 17 ++++++++++++ web/src/services/api/image.ts | 25 +++++++++++------ web/test/agent-prompt-cache.test.ts | 27 +++++++++++++++++++ 4 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 web/src/lib/openai-prompt-cache.ts create mode 100644 web/test/agent-prompt-cache.test.ts diff --git a/web/src/components/canvas/canvas-assistant-panel.tsx b/web/src/components/canvas/canvas-assistant-panel.tsx index 5a1f001a0..b51959dea 100644 --- a/web/src/components/canvas/canvas-assistant-panel.tsx +++ b/web/src/components/canvas/canvas-assistant-panel.tsx @@ -23,6 +23,7 @@ import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; import { CanvasNodeType, type CanvasAssistantMessage, type CanvasAssistantPendingBackendSession, type CanvasAssistantReference, type CanvasAssistantSession, type CanvasNodeData } from "@/types/canvas"; import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store"; import { previewCanvasAgentOps, summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentOperationImpact, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops"; +import { canvasAgentPromptCacheKey } from "@/lib/openai-prompt-cache"; export const CANVAS_AGENT_PANEL_MOTION_MS = 500; const PANEL_MOTION_SECONDS = CANVAS_AGENT_PANEL_MOTION_MS / 1000; @@ -412,7 +413,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project const result = await requestToolResponse({ ...requestConfig, systemPrompt: "" }, messages, ONLINE_AGENT_TOOLS, "required", (text) => { streamed = text; if (text.trim()) upsertMessage(sessionId, { id: assistantId, role: "assistant", text }); - }); + }, { promptCacheKey: canvasAgentPromptCacheKey(sessionId) }); addOnlineLog("模型工具回复", result); if (result.toolCalls.length) { const writableCalls = result.toolCalls.filter(isWritableToolCall); @@ -468,7 +469,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project const next = await requestToolResponse({ ...requestConfig, systemPrompt: "" }, nextMessages, ONLINE_AGENT_TOOLS, "auto", (text) => { streamed = text; if (text.trim()) upsertMessage(sessionId, { id: assistantId, role: "assistant", text }); - }); + }, { promptCacheKey: canvasAgentPromptCacheKey(sessionId) }); addOnlineLog(`Agent Tool Loop ${step + 1} 回复`, next); if (next.toolCalls.length) { const writableCalls = next.toolCalls.filter(isWritableToolCall); diff --git a/web/src/lib/openai-prompt-cache.ts b/web/src/lib/openai-prompt-cache.ts new file mode 100644 index 000000000..8903936ef --- /dev/null +++ b/web/src/lib/openai-prompt-cache.ts @@ -0,0 +1,17 @@ +export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64; + +export function clampOpenAIPromptCacheKey(value?: string) { + const key = value?.trim(); + if (!key) return undefined; + return Array.from(key).slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join(""); +} + +export function canvasAgentPromptCacheKey(sessionId: string) { + const value = sessionId.trim(); + return value ? clampOpenAIPromptCacheKey(`canvas-agent:${value}`) : undefined; +} + +export function withOpenAIPromptCacheKey>(body: T, value?: string): T & { prompt_cache_key?: string } { + const key = clampOpenAIPromptCacheKey(value); + return key ? { ...body, prompt_cache_key: key } : body; +} diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index 10c9a97eb..3861ed849 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -7,6 +7,7 @@ import { buildImageReferencePromptText } from "@/lib/image-reference-prompt"; import { channelRequest } from "@/services/api/custom-channel-relay"; import { imageToDataUrl } from "@/services/image-storage"; import type { ReferenceImage } from "@/types/image"; +import { withOpenAIPromptCacheKey } from "@/lib/openai-prompt-cache"; export type AiTextMessage = { role: "system" | "user" | "assistant"; @@ -100,7 +101,7 @@ type GeminiPayload = { promptFeedback?: { blockReason?: string }; }; type GeminiStreamState = { buffer: string; text: string; toolCalls: ResponseToolCall[]; error?: string }; -type RequestOptions = { signal?: AbortSignal }; +type RequestOptions = { signal?: AbortSignal; promptCacheKey?: string }; const QUALITY_BASE: Record = { low: 1024, @@ -945,13 +946,21 @@ export async function requestToolResponse(config: AiConfig, messages: ResponseIn parallel_tool_calls: false, }, onDelta, options); } - return await requestStreamingResponse(requestConfig, { - model: requestConfig.model, - input: toResponseInput(withSystemMessage(requestConfig, messages)), - tools: tools.map(toResponseTool), - tool_choice: toolChoice, - parallel_tool_calls: false, - }, onDelta, options); + return await requestStreamingResponse( + requestConfig, + withOpenAIPromptCacheKey( + { + model: requestConfig.model, + input: toResponseInput(withSystemMessage(requestConfig, messages)), + tools: tools.map(toResponseTool), + tool_choice: toolChoice, + parallel_tool_calls: false, + }, + options?.promptCacheKey, + ), + onDelta, + options, + ); } catch (error) { throw new Error(readAxiosError(error, "请求失败")); } diff --git a/web/test/agent-prompt-cache.test.ts b/web/test/agent-prompt-cache.test.ts new file mode 100644 index 000000000..00371c990 --- /dev/null +++ b/web/test/agent-prompt-cache.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; + +import { canvasAgentPromptCacheKey, clampOpenAIPromptCacheKey, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH, withOpenAIPromptCacheKey } from "../src/lib/openai-prompt-cache"; + +describe("Agent prompt cache", () => { + test("同一会话生成稳定且隔离的缓存键", () => { + expect(canvasAgentPromptCacheKey("session-a")).toBe("canvas-agent:session-a"); + expect(canvasAgentPromptCacheKey("session-a")).toBe(canvasAgentPromptCacheKey("session-a")); + expect(canvasAgentPromptCacheKey("session-a")).not.toBe(canvasAgentPromptCacheKey("session-b")); + expect(canvasAgentPromptCacheKey(" ")).toBeUndefined(); + }); + + test("缓存键按 OpenAI 上限截断", () => { + const key = clampOpenAIPromptCacheKey(`canvas-agent:${"x".repeat(100)}`); + expect(Array.from(key || "")).toHaveLength(OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH); + }); + + test("Responses 请求体携带缓存键", () => { + const body = withOpenAIPromptCacheKey({ model: "test-model", input: [] }, " canvas-agent:session-a "); + expect(body.prompt_cache_key).toBe("canvas-agent:session-a"); + }); + + test("空缓存键不会写入 Responses 请求体", () => { + const body = withOpenAIPromptCacheKey({ model: "test-model", input: [] }, " "); + expect(body).not.toHaveProperty("prompt_cache_key"); + }); +});