diff --git a/Memory/readme.md b/Memory/readme.md index 14e79817..7f240574 100644 --- a/Memory/readme.md +++ b/Memory/readme.md @@ -72,11 +72,11 @@ The `MEMMY_MEMORY_HOST`, `MEMMY_MEMORY_PORT`, and `MEMMY_MEMORY_DB` environment variables override the corresponding server settings. The `MEMORY_SERVICE_*` aliases are also accepted. -Known OpenAI embedding model names are tokenized and split automatically with -a safe 7,500-token budget. When an OpenAI-compatible endpoint uses an opaque -deployment alias, set `memmyMemory.embedding.maxInputTokens` or -`MEMMY_EMBEDDING_MAX_INPUT_TOKENS` to a safe per-input budget so the same -token-aware splitting is enabled. +OpenAI-compatible embedding inputs are tokenized and split automatically with +a conservative 7,500-token per-input budget. Set +`memmyMemory.embedding.maxInputTokens` or +`MEMMY_EMBEDDING_MAX_INPUT_TOKENS` to use a smaller budget for a provider with +a shorter context window. When `storage.token`, `MEMMY_MEMORY_TOKEN`, or `MEMORY_SERVICE_TOKEN` is set, all HTTP routes except `GET /api/v1/health` require that token as a bearer token diff --git a/Memory/src/model/embedder.ts b/Memory/src/model/embedder.ts index a7aac71b..d1165d6a 100644 --- a/Memory/src/model/embedder.ts +++ b/Memory/src/model/embedder.ts @@ -228,7 +228,7 @@ class HttpEmbedder implements Embedder { const chunkVectors: number[][] = []; for (const batch of plan.batches) { chunkVectors.push(...await this.requestOpenAiShape( - batch.map((chunk) => chunk.tokens), + batch.map((chunk) => chunk.input), provider, url, role diff --git a/Memory/src/model/openai-embedding-inputs.ts b/Memory/src/model/openai-embedding-inputs.ts index 30e79632..8a53941a 100644 --- a/Memory/src/model/openai-embedding-inputs.ts +++ b/Memory/src/model/openai-embedding-inputs.ts @@ -6,6 +6,7 @@ const OPENAI_EMBEDDING_BATCH_TOKEN_BUDGET = 290_000; export interface OpenAiEmbeddingChunk { originalIndex: number; tokens: number[]; + input: string | number[]; } export interface OpenAiEmbeddingPlan { @@ -22,7 +23,10 @@ export function planOpenAiEmbeddingInputs( configuredMaxInputTokens?: number ): OpenAiEmbeddingPlan | null { const inputTokenBudget = resolveInputTokenBudget(model, configuredMaxInputTokens); - if (!inputTokenBudget) return null; + // Explicit budgets retain the historical token-id request shape for + // deployments that opt into it; opaque aliases use text chunks so their + // model-specific tokenizer is still applied by the provider. + const useTokenIds = isKnownOpenAiEmbeddingModel(model) || configuredMaxInputTokens !== undefined; encoder ??= get_encoding("cl100k_base"); const encoded = texts.map((text) => Array.from(encoder!.encode(text, [], []))); const totalTokens = encoded.reduce((sum, tokens) => sum + tokens.length, 0); @@ -30,13 +34,26 @@ export function planOpenAiEmbeddingInputs( encoded.every((tokens) => tokens.length <= inputTokenBudget)) return null; const chunks = encoded.flatMap((tokens, originalIndex) => { - if (tokens.length === 0) return [{ originalIndex, tokens }]; + if (tokens.length === 0) return [{ originalIndex, tokens, input: useTokenIds ? tokens : "" }]; + const tokenBytes = useTokenIds + ? undefined + : tokens.map((token) => encoder!.decode_single_token_bytes(token)); const items: OpenAiEmbeddingChunk[] = []; - for (let offset = 0; offset < tokens.length; offset += inputTokenBudget) { + for (let offset = 0; offset < tokens.length;) { + let end = Math.min(tokens.length, offset + inputTokenBudget); + if (!useTokenIds && end < tokens.length) { + while (end > offset && startsWithContinuationByte(tokenBytes?.[end])) { + end -= 1; + } + if (end === offset) end = Math.min(tokens.length, offset + inputTokenBudget); + } + const chunkTokens = tokens.slice(offset, end); items.push({ originalIndex, - tokens: tokens.slice(offset, offset + inputTokenBudget) + tokens: chunkTokens, + input: useTokenIds ? chunkTokens : decodeTokenBytes(tokenBytes!.slice(offset, end)) }); + offset = end; } return items; }); @@ -74,14 +91,28 @@ function isKnownOpenAiEmbeddingModel(model?: string): boolean { return /(?:^|[/.:])text-embedding-(?:3-(?:small|large)|ada-002)(?:$|[/.:])/i.test(model?.trim() ?? ""); } -function resolveInputTokenBudget(model?: string, configured?: number): number | null { +function resolveInputTokenBudget(_model?: string, configured?: number): number { const explicit = typeof configured === "number" && Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : undefined; - if (!isKnownOpenAiEmbeddingModel(model) && !explicit) return null; + // OpenAI-compatible deployments frequently expose an opaque deployment + // alias instead of the upstream model id. We cannot safely assume that + // alias has a larger context window, so apply the same conservative budget + // used for known OpenAI embedding models unless the caller opts into a + // smaller budget explicitly. return Math.min(explicit ?? OPENAI_EMBEDDING_INPUT_TOKEN_BUDGET, OPENAI_EMBEDDING_INPUT_TOKEN_BUDGET); } +function decodeTokenBytes(tokenBytes: Uint8Array[]): string { + const bytes = tokenBytes.flatMap((value) => Array.from(value)); + return new TextDecoder().decode(Uint8Array.from(bytes)); +} + +function startsWithContinuationByte(bytes: Uint8Array | undefined): boolean { + const first = bytes?.[0]; + return first !== undefined && (first & 0xc0) === 0x80; +} + function batchChunks(chunks: OpenAiEmbeddingChunk[]): OpenAiEmbeddingChunk[][] { const batches: OpenAiEmbeddingChunk[][] = []; let current: OpenAiEmbeddingChunk[] = []; diff --git a/Memory/tests/embedder.test.ts b/Memory/tests/embedder.test.ts index 3f94e71e..ddd74da4 100644 --- a/Memory/tests/embedder.test.ts +++ b/Memory/tests/embedder.test.ts @@ -1,6 +1,7 @@ import { mkdir, rm } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; +import { get_encoding } from "tiktoken"; import { afterEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_MEMMY_CONFIG } from "../src/config/index.js"; import { createEmbedder } from "../src/model/embedder.js"; @@ -138,6 +139,56 @@ describe("embedder", () => { expect(sentInputs.every((input) => input.length <= 512)).toBe(true); }); + it("uses the conservative token budget for an opaque deployment alias by default", async () => { + const sentInputs: string[] = []; + vi.stubGlobal("fetch", vi.fn(async (_url, init) => { + const body = JSON.parse(String(init?.body)) as { input: string[] }; + sentInputs.push(...body.input); + return new Response(JSON.stringify({ + data: body.input.map(() => ({ embedding: [1, 0] })) + }), { status: 200, headers: { "content-type": "application/json" } }); + })); + const embedder = createEmbedder({ + ...DEFAULT_MEMMY_CONFIG.embedding, + provider: "openai_compatible", + endpoint: "https://api.example.test/v1", + model: "production-embedding-deployment", + apiKey: "sk-test", + cache: false, + maxRetries: 0 + }); + + await expect(embedder.embedOne(" memory".repeat(8_001))).resolves.toEqual([1, 0]); + + expect(sentInputs.length).toBeGreaterThan(1); + const encoder = get_encoding("cl100k_base"); + expect(sentInputs.every((input) => encoder.encode(input).length <= 7_500)).toBe(true); + expect(sentInputs.join("")).toBe(" memory".repeat(8_001)); + }); + + it("keeps short opaque deployment inputs as text", async () => { + let requestInput: unknown; + vi.stubGlobal("fetch", vi.fn(async (_url, init) => { + requestInput = (JSON.parse(String(init?.body)) as { input: unknown }).input; + return new Response(JSON.stringify({ data: [{ embedding: [1, 0] }] }), { + status: 200, + headers: { "content-type": "application/json" } + }); + })); + const embedder = createEmbedder({ + ...DEFAULT_MEMMY_CONFIG.embedding, + provider: "openai_compatible", + endpoint: "https://api.example.test/v1", + model: "production-embedding-deployment", + apiKey: "sk-test", + cache: false, + maxRetries: 0 + }); + + await expect(embedder.embedOne("short memory")).resolves.toEqual([1, 0]); + expect(requestInput).toEqual(["short memory"]); + }); + it("keeps chunked OpenAI embedding request batches below the aggregate token budget", async () => { const requestTokenCounts: number[] = []; vi.stubGlobal("fetch", vi.fn(async (_url, init) => { diff --git a/Memory/tests/viewer-memories.test.ts b/Memory/tests/viewer-memories.test.ts new file mode 100644 index 00000000..c1fa465c --- /dev/null +++ b/Memory/tests/viewer-memories.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import type { TraceDTO } from "../agent-contract/dto.js"; +import { pickSummary } from "../viewer/src/views/memory-summary.js"; + +describe("Memmy Viewer memories", () => { + it("shows the user query while the summary is pending and the summary once ready", () => { + const trace = { + summary: "摘要排队中", + userText: "修复记忆列表的摘要占位文案", + agentText: "已开始排查。", + } as TraceDTO; + + expect(pickSummary(trace)).toBe("修复记忆列表的摘要占位文案"); + expect(pickSummary({ + ...trace, + summary: "记忆列表在摘要生成前会展示用户请求", + })).toBe("记忆列表在摘要生成前会展示用户请求"); + }); +}); diff --git a/Memory/viewer/src/api/types.ts b/Memory/viewer/src/api/types.ts index e9e319fc..a08eb17e 100644 --- a/Memory/viewer/src/api/types.ts +++ b/Memory/viewer/src/api/types.ts @@ -21,14 +21,14 @@ export type { ToolOutcomeDTO, TurnInputDTO, TurnResultDTO, -} from "../../../agent-contract/dto"; +} from "../../../agent-contract/dto.js"; export type { CoreEvent, CoreEventType, -} from "../../../agent-contract/events"; +} from "../../../agent-contract/events.js"; export type { LogRecord, LogLevel, -} from "../../../agent-contract/log-record"; +} from "../../../agent-contract/log-record.js"; diff --git a/Memory/viewer/src/stores/i18n.ts b/Memory/viewer/src/stores/i18n.ts index 3ca389b7..abfb277e 100644 --- a/Memory/viewer/src/stores/i18n.ts +++ b/Memory/viewer/src/stores/i18n.ts @@ -898,7 +898,7 @@ const en = { "Configure an embedding provider before repairing or rebuilding vectors.", "settings.embedding.maxInputTokens.label": "Maximum input tokens", "settings.embedding.maxInputTokens.hint": - "Known OpenAI embedding models use a safe 7,500-token budget automatically. Set a lower budget for custom deployment aliases; rebuild vectors after changing it.", + "OpenAI-compatible embedding models use a conservative 7,500-token budget automatically. Set a lower budget for providers with shorter context windows; rebuild vectors after changing it.", "settings.embedding.providerBatchSize.label": "Embedding API batch size", "settings.embedding.providerBatchSize.hint": "Maximum texts per provider request. Rejected oversized batches are split automatically.", @@ -1854,7 +1854,7 @@ const zh: Record = { "可用 {ready}/{total};缺失 {missing};维度不匹配 {mismatch};当前维度 {dim}。", "settings.embedding.maintenance.unavailable": "请先配置嵌入模型,再修复或重建向量。", "settings.embedding.maxInputTokens.label": "单条输入最大 Token 数", - "settings.embedding.maxInputTokens.hint": "已知 OpenAI Embedding 模型自动使用安全的 7500 Token 预算;自定义部署别名可设置更低预算,修改后请重建向量。", + "settings.embedding.maxInputTokens.hint": "OpenAI 兼容 Embedding 模型会自动使用保守的 7500 Token 预算;上下文窗口更短时可设置更低预算,修改后请重建向量。", "settings.embedding.providerBatchSize.label": "Embedding API 批量大小", "settings.embedding.providerBatchSize.hint": "单次模型请求最多发送的文本数;超限失败时会自动拆批。", "settings.embedding.repair": "修复缺失/错维", diff --git a/Memory/viewer/src/views/MemoriesView.tsx b/Memory/viewer/src/views/MemoriesView.tsx index 219e1990..9c81fcfb 100644 --- a/Memory/viewer/src/views/MemoriesView.tsx +++ b/Memory/viewer/src/views/MemoriesView.tsx @@ -67,6 +67,7 @@ import { TEAM_SHARING_UI_ENABLED } from "../features"; import type { TraceDTO } from "../api/types"; import { displayMemoryId } from "../utils/memory-id"; import { areAllIdsSelected, toggleIdsInSelection } from "../utils/selection"; +import { pickSummary, usableSummary } from "./memory-summary"; import { loadHubSharingEnabled, normalizeShareScope, @@ -644,16 +645,6 @@ function TraceMemoriesView() { // ─── helpers ───────────────────────────────────────────────────────────── -function pickSummary(trace: TraceDTO): string { - const s = usableSummary(trace.summary); - if (s) return s; - const u = (trace.userText ?? "").replace(/\s+/g, " ").trim(); - if (u) return u.length > 180 ? u.slice(0, 177) + "…" : u; - const a = (trace.agentText ?? "").replace(/\s+/g, " ").trim(); - if (a) return a.length > 180 ? a.slice(0, 177) + "…" : a; - return "(empty trace)"; -} - function pickGroupSummary(group: MemoryGroup): string { const headSummary = usableSummary(group.head.summary); if (headSummary) return headSummary; @@ -667,17 +658,6 @@ function pickGroupSummary(group: MemoryGroup): string { return pickSummary(group.head); } -function usableSummary(summary: string | null | undefined): string { - const s = (summary ?? "").trim(); - if (!s || isPlaceholderSummary(s)) return ""; - return s; -} - -function isPlaceholderSummary(summary: string): boolean { - const s = summary.trim().toLowerCase(); - return s === "(empty turn)" || s === "(empty trace)" || s === "(empty)"; -} - function detectRole(trace: TraceDTO): "user" | "assistant" | "tool" | "" { if ((trace.toolCalls?.length ?? 0) > 0) return "tool"; if (trace.userText && trace.userText.length > (trace.agentText?.length ?? 0)) { diff --git a/Memory/viewer/src/views/memory-summary.ts b/Memory/viewer/src/views/memory-summary.ts new file mode 100644 index 00000000..140dcb79 --- /dev/null +++ b/Memory/viewer/src/views/memory-summary.ts @@ -0,0 +1,34 @@ +import type { TraceDTO } from "../api/types.js"; + +const PLACEHOLDER_SUMMARIES = new Set([ + "(empty turn)", + "(empty trace)", + "(empty)", + "摘要排队中", + "摘要整理中", + "摘要总结中", +]); + +export function pickSummary(trace: TraceDTO): string { + const summary = usableSummary(trace.summary); + if (summary) return summary; + + const userText = compact(trace.userText); + if (userText) return truncate(userText); + + const agentText = compact(trace.agentText); + return agentText ? truncate(agentText) : "(empty trace)"; +} + +export function usableSummary(summary: string | null | undefined): string { + const value = (summary ?? "").trim(); + return value && !PLACEHOLDER_SUMMARIES.has(value.toLowerCase()) ? value : ""; +} + +function compact(value: string | null | undefined): string { + return (value ?? "").replace(/\s+/g, " ").trim(); +} + +function truncate(value: string): string { + return value.length > 180 ? `${value.slice(0, 177)}…` : value; +}