perf(ai): kontiguitas batch budget + max_tokens dinamis + urutan kronologis RETURNING
- pickBatchWithinBudget: stop di overflow pertama (break), bukan skip — batch tetap prefix kronologis tanpa gap analisis di tengah timeline. Diekstrak ke batchBudget.ts (pure, estimator di-inject) + regression test. - callModerationLLM: param opsional maxTokens; text/media caller menghitung ceiling dari estimasi prompt (floor 2048, cap 16384) — batch kecil tak lagi reserve window completion 16k. - getPending/IncompleteMessagesByConversation: sort hasil UPDATE..RETURNING by created_at ASC — Postgres tak menjamin urutan, konsumen (anchor konteks messages[0], prefix batch) bergantung pada urutan kronologis.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* batchBudget.ts
|
||||
*
|
||||
* Pure batch-sizing helper extracted from batchProcessor.ts so it can be
|
||||
* unit-tested without pulling in the Piscina worker pool, message store,
|
||||
* or any other side-effectful import chain.
|
||||
*/
|
||||
import type { MessageRecord } from "../message-capture/types.js";
|
||||
|
||||
/** Token estimator contract (satisfied by conversationContext.estimateTokens). */
|
||||
export type TokenEstimator = (text: string) => number;
|
||||
|
||||
/**
|
||||
* Picks a batch of messages within a token budget.
|
||||
* `tokensPerMessage` accounts for JSON structure overhead around each entry.
|
||||
* The estimator is injected so this stays a pure function — callers in the
|
||||
* batch pipeline pass the tiktoken-based estimateTokens.
|
||||
*/
|
||||
export function pickBatchWithinBudget(
|
||||
messages: MessageRecord[],
|
||||
maxTokens: number,
|
||||
tokensPerMessage: number,
|
||||
estimateTokens: TokenEstimator,
|
||||
): MessageRecord[] {
|
||||
const batch: MessageRecord[] = [];
|
||||
let usedTokens = 0;
|
||||
|
||||
for (const msg of messages) {
|
||||
const content = msg.edited_content ?? msg.content;
|
||||
const msgTokens = estimateTokens(content) + tokensPerMessage;
|
||||
|
||||
// Stop at the first overflow instead of skipping: input is ordered
|
||||
// created_at ASC, so a contiguous chronological prefix keeps the batch
|
||||
// gap-free. Skipped-over messages would leave unanalyzed holes mid-
|
||||
// timeline; anything past the budget is picked up by the next wave
|
||||
// (processBatch always re-schedules after success).
|
||||
if (usedTokens + msgTokens > maxTokens) {
|
||||
break;
|
||||
}
|
||||
batch.push(msg);
|
||||
usedTokens += msgTokens;
|
||||
}
|
||||
|
||||
return batch;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { config } from "../../shared/config/config.js";
|
||||
import { isAgeRestrictedMetadata } from "../message-capture/messageMetadata.js";
|
||||
import { messageStore } from "../message-capture/messageStore.js";
|
||||
import type { MessageRecord } from "../message-capture/types.js";
|
||||
import { pickBatchWithinBudget as pickBatchWithinBudgetPure } from "./batchBudget.js";
|
||||
import { workerPool } from "./circuitBreaker.js";
|
||||
import { estimateTokens } from "./conversationContext.js";
|
||||
import {
|
||||
@@ -39,30 +40,21 @@ export let activeRequests = 0;
|
||||
|
||||
/**
|
||||
* Picks a batch of messages within a token budget.
|
||||
* `tokensPerMessage` accounts for JSON structure overhead around each entry.
|
||||
* Uses a rough character-based token estimate (avoids async formatMessageForPrompt
|
||||
* since this function runs in a synchronous promise chain).
|
||||
* Thin wrapper over the pure helper in batchBudget.ts (kept here so the
|
||||
* existing import surface stays stable); passes the tiktoken-based
|
||||
* estimateTokens. See batchBudget.ts for the overflow-stopping semantics.
|
||||
*/
|
||||
export function pickBatchWithinBudget(
|
||||
messages: MessageRecord[],
|
||||
maxTokens: number,
|
||||
tokensPerMessage: number,
|
||||
): MessageRecord[] {
|
||||
const batch: MessageRecord[] = [];
|
||||
let usedTokens = 0;
|
||||
|
||||
for (const msg of messages) {
|
||||
const content = msg.edited_content ?? msg.content;
|
||||
// Accurate token count via tiktoken (+ overhead for JSON structure)
|
||||
const msgTokens = estimateTokens(content) + tokensPerMessage;
|
||||
|
||||
if (usedTokens + msgTokens <= maxTokens) {
|
||||
batch.push(msg);
|
||||
usedTokens += msgTokens;
|
||||
}
|
||||
}
|
||||
|
||||
return batch;
|
||||
return pickBatchWithinBudgetPure(
|
||||
messages,
|
||||
maxTokens,
|
||||
tokensPerMessage,
|
||||
estimateTokens,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -49,6 +49,10 @@ export async function callModerationLLM(
|
||||
targetIds: string[],
|
||||
label: string,
|
||||
signal?: AbortSignal,
|
||||
// Output-side token cap. Defaults to the previous hard-coded value; batch
|
||||
// callers pass a prompt-derived ceiling so small batches don't reserve a
|
||||
// 16k completion budget (some routers pre-allocate KV cache per max_tokens).
|
||||
maxTokens?: number,
|
||||
): Promise<{
|
||||
results: AnalysisResult[];
|
||||
raw: ChatCompletion | null;
|
||||
@@ -75,7 +79,7 @@ export async function callModerationLLM(
|
||||
];
|
||||
const completion = await llmChat({
|
||||
messages,
|
||||
max_tokens: 16384,
|
||||
max_tokens: maxTokens ?? 16384,
|
||||
jsonResponse: { type: "json_object" },
|
||||
retries: 0,
|
||||
signal,
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
MessageRecord,
|
||||
} from "../message-capture/types.js";
|
||||
import { getChannelCulture } from "./channelCultureStore.js";
|
||||
import { estimateTokens } from "./conversationContext.js";
|
||||
import type { RetryState } from "./llmCaller.js";
|
||||
import { callModerationLLM } from "./llmCaller.js";
|
||||
import { prepareMediaMessage } from "./mediaAnalysisClient.js";
|
||||
@@ -87,11 +88,22 @@ export async function runMediaBatch(
|
||||
timeoutId.unref();
|
||||
|
||||
try {
|
||||
// Output budget scales with the prompt (see textBatchProcessor): small
|
||||
// media batches don't need the full 16k completion window.
|
||||
const promptEstimate =
|
||||
2000 +
|
||||
estimateTokens(userContent) +
|
||||
targets.reduce((sum, m) => sum + estimateTokens(m.content ?? "") + 50, 0);
|
||||
const dynamicMaxTokens = Math.min(
|
||||
16384,
|
||||
Math.max(2048, Math.ceil(promptEstimate * 1.5)),
|
||||
);
|
||||
const result = await callModerationLLM(
|
||||
async (_state: RetryState) => ({ system: systemText, user: userContent }),
|
||||
targetIds,
|
||||
`media-batch:${targetIds.length}msgs`,
|
||||
abortController.signal,
|
||||
dynamicMaxTokens,
|
||||
);
|
||||
log.info(
|
||||
{ mediaCount: targets.length, resultCount: result.results.length },
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
MessageRecord,
|
||||
} from "../message-capture/types.js";
|
||||
import { getChannelCulture } from "./channelCultureStore.js";
|
||||
import { estimateTokens } from "./conversationContext.js";
|
||||
import type { ModerationPromptContent, RetryState } from "./llmCaller.js";
|
||||
import { callModerationLLM } from "./llmCaller.js";
|
||||
import { analyzeSingleMediaImage } from "./mediaAnalysisClient.js";
|
||||
@@ -341,11 +342,29 @@ export async function runTextOnlyBatch(
|
||||
|
||||
let batchResult: { results: AnalysisResult[]; raw: unknown };
|
||||
try {
|
||||
// Output budget scales with the prompt: the JSON verdict block is
|
||||
// roughly proportional to message count, so a small sub-batch doesn't
|
||||
// need to reserve a full 16k completion window. Estimated here from
|
||||
// raw materials (system/rules baseline ~2k + context + message
|
||||
// bodies) instead of inside buildContent, because max_tokens must be
|
||||
// known at call time.
|
||||
const subBatchPromptEstimate =
|
||||
2000 +
|
||||
estimateTokens(contextBlock ?? "") +
|
||||
batch.reduce(
|
||||
(sum, m) => sum + estimateTokens(m.edited_content ?? m.content) + 50,
|
||||
0,
|
||||
);
|
||||
const dynamicMaxTokens = Math.min(
|
||||
16384,
|
||||
Math.max(2048, Math.ceil(subBatchPromptEstimate * 1.5)),
|
||||
);
|
||||
batchResult = await callModerationLLM(
|
||||
buildContent,
|
||||
targetIds,
|
||||
`text-batch-${i + 1}`,
|
||||
abortController.signal,
|
||||
dynamicMaxTokens,
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (err.name === "AbortError" || abortController.signal.aborted) {
|
||||
|
||||
Reference in New Issue
Block a user