feat(lpt-ai):异步任务队列 + 提交/轮询路由
lpt-ai 新增异步任务处理能力,LLM 调用改为后台 Worker 顺序执行:
新增 src/task-queue.ts:
- createTask(type, params) → taskId(randomUUID)
- getTask(taskId) → { status, result?, error? }
- 单线程 Worker(setImmediate 链),同一时刻只处理一个 LLM 调用
- 按类型分派到对应的 prompt 函数
- TTL 清理:5 分钟/次,移除完成超过 1 小时的记录
- 异常隔离 + logStore 集成
修改 src/routes/ai.ts:
- POST /ai/tasks:接收 { type, params },返回 201 { taskId }
- GET /ai/tasks/:taskId:返回任务状态与结果(pending/running/done/failed)
- 旧同步端点完全不动,向后兼容
This commit is contained in:
@@ -6,6 +6,7 @@ import type { FastifyInstance } from "fastify";
|
||||
import { chat, isAvailable, loadConfig, LlmError } from "../llm/client.js";
|
||||
import { aggregateReportPrompt, compareRecallPrompt, generateMindMapPrompt, type FragmentInput } from "../llm/prompts.js";
|
||||
import { logStore } from "../admin/store.js";
|
||||
import { createTask, getTask, type TaskType } from "../task-queue.js";
|
||||
|
||||
interface AggregateReportBody {
|
||||
taskName: string;
|
||||
@@ -173,4 +174,31 @@ export async function aiRoutes(app: FastifyInstance): Promise<void> {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// ============ 异步任务(提交 + 轮询)============
|
||||
|
||||
/** 提交 AI 任务,立即返回 taskId */
|
||||
app.post<{ Body: { type: string; params: Record<string, unknown> } }>("/ai/tasks", async (request, reply) => {
|
||||
const { type, params } = request.body ?? {};
|
||||
|
||||
if (!type || !["aggregate-report", "generate-mind-map", "compare-recall"].includes(type)) {
|
||||
return reply.status(400).send({ error: "无效的 type,允许: aggregate-report, generate-mind-map, compare-recall" });
|
||||
}
|
||||
if (!params || typeof params !== "object") {
|
||||
return reply.status(400).send({ error: "params 必须为非空对象" });
|
||||
}
|
||||
|
||||
const taskId = createTask(type as TaskType, params as Record<string, unknown>);
|
||||
return reply.status(201).send({ taskId });
|
||||
});
|
||||
|
||||
/** 查询任务状态与结果 */
|
||||
app.get<{ Params: { taskId: string } }>("/ai/tasks/:taskId", async (request, reply) => {
|
||||
const { taskId } = request.params;
|
||||
const task = getTask(taskId);
|
||||
if (!task) {
|
||||
return reply.status(404).send({ error: "任务不存在" });
|
||||
}
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* 异步任务队列 —— 接收任务,后台 Worker 顺序处理,提供查询。
|
||||
*
|
||||
* 设计要点:
|
||||
* - 单线程 Worker(setImmediate 链),同一时刻只处理一个 LLM 调用
|
||||
* - TTL 清理:5 分钟一次,移除完成超过 1 小时的记录
|
||||
* - 异常隔离:单个任务失败不影响后续任务
|
||||
* - 日志复用 logStore,与管理面板日志统一
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { chat, isAvailable, loadConfig, LlmError } from "./llm/client.js";
|
||||
import { aggregateReportPrompt, compareRecallPrompt, generateMindMapPrompt } from "./llm/prompts.js";
|
||||
import { logStore } from "./admin/store.js";
|
||||
|
||||
// ---- 类型 ----
|
||||
|
||||
export type TaskType = "aggregate-report" | "generate-mind-map" | "compare-recall";
|
||||
|
||||
export type TaskStatus = "pending" | "running" | "done" | "failed";
|
||||
|
||||
export interface TaskRecord {
|
||||
id: string;
|
||||
type: TaskType;
|
||||
params: Record<string, unknown>;
|
||||
status: TaskStatus;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
createdAt: string; // ISO 8601
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
// ---- 存储 ----
|
||||
|
||||
const records = new Map<string, TaskRecord>();
|
||||
const queue: TaskRecord[] = [];
|
||||
let processing = false;
|
||||
|
||||
// ---- 公开 API ----
|
||||
|
||||
export function createTask(type: TaskType, params: Record<string, unknown>): string {
|
||||
const id = randomUUID();
|
||||
const task: TaskRecord = {
|
||||
id,
|
||||
type,
|
||||
params,
|
||||
status: "pending",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
records.set(id, task);
|
||||
queue.push(task);
|
||||
setImmediate(() => tryProcessNext());
|
||||
return id;
|
||||
}
|
||||
|
||||
export function getTask(id: string): TaskRecord | undefined {
|
||||
const t = records.get(id);
|
||||
if (!t) return undefined;
|
||||
// 返回浅拷贝,防止外部修改
|
||||
return { ...t };
|
||||
}
|
||||
|
||||
// ---- 后台 Worker ----
|
||||
|
||||
async function tryProcessNext(): Promise<void> {
|
||||
if (processing || queue.length === 0) return;
|
||||
processing = true;
|
||||
|
||||
const task = queue.shift()!;
|
||||
|
||||
try {
|
||||
task.status = "running";
|
||||
task.startedAt = new Date().toISOString();
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
if (!isAvailable(config)) {
|
||||
throw new LlmError("LLM API Key 未配置", 503);
|
||||
}
|
||||
|
||||
const messages = buildMessages(task);
|
||||
let callOptions: { temperature?: number; maxTokens?: number } | undefined;
|
||||
if (task.type === "compare-recall") {
|
||||
callOptions = { temperature: 0.2, maxTokens: 4096 };
|
||||
} else if (task.type === "generate-mind-map") {
|
||||
callOptions = { temperature: 0.3, maxTokens: 4096 };
|
||||
} else {
|
||||
callOptions = { temperature: 0.3, maxTokens: 2048 };
|
||||
}
|
||||
|
||||
const raw = await chat(config, messages, callOptions);
|
||||
|
||||
let result: unknown;
|
||||
if (task.type === "aggregate-report") {
|
||||
result = { report: raw.trim(), model: config.model };
|
||||
} else if (task.type === "generate-mind-map") {
|
||||
result = { outline: raw.trim(), model: config.model };
|
||||
} else {
|
||||
// compare-recall: 清理可能的 ```json 包裹
|
||||
const clean = raw.trim().replace(/^```(?:json)?\s*/, "").replace(/\s*```$/, "");
|
||||
result = JSON.parse(clean);
|
||||
}
|
||||
|
||||
task.status = "done";
|
||||
task.result = result;
|
||||
task.completedAt = new Date().toISOString();
|
||||
|
||||
// 记录到管理面板日志
|
||||
logStore.add({
|
||||
timestamp: new Date().toISOString(),
|
||||
endpoint: `/ai/tasks#${task.type}`,
|
||||
model: config.model,
|
||||
durationMs: task.startedAt ? Date.parse(task.completedAt) - Date.parse(task.startedAt) : 0,
|
||||
status: 200,
|
||||
requestBody: task.params,
|
||||
responseBody: result,
|
||||
messages,
|
||||
params: callOptions,
|
||||
});
|
||||
|
||||
} catch (err: unknown) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
task.status = "failed";
|
||||
task.error = errMsg;
|
||||
task.completedAt = new Date().toISOString();
|
||||
|
||||
const config = loadConfig();
|
||||
logStore.add({
|
||||
timestamp: new Date().toISOString(),
|
||||
endpoint: `/ai/tasks#${task.type}`,
|
||||
model: config.model,
|
||||
durationMs: task.startedAt ? Date.parse(task.completedAt) - Date.parse(task.startedAt) : 0,
|
||||
status: err instanceof LlmError ? err.statusCode : 500,
|
||||
requestBody: task.params,
|
||||
responseBody: {},
|
||||
error: errMsg,
|
||||
});
|
||||
} finally {
|
||||
processing = false;
|
||||
// 处理下一个任务
|
||||
if (queue.length > 0) {
|
||||
setImmediate(() => tryProcessNext());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Prompt 构建 ----
|
||||
|
||||
function buildMessages(task: TaskRecord) {
|
||||
const p = task.params;
|
||||
switch (task.type) {
|
||||
case "aggregate-report": {
|
||||
const fragments = p.fragments as Array<{ content: string }> | undefined;
|
||||
return aggregateReportPrompt(
|
||||
p.taskName as string,
|
||||
fragments ?? [],
|
||||
p.expectation as string | undefined,
|
||||
);
|
||||
}
|
||||
case "generate-mind-map": {
|
||||
const reports = p.reports as string[] | undefined;
|
||||
const fragments = p.fragments as string[] | undefined;
|
||||
return generateMindMapPrompt(
|
||||
p.taskName as string,
|
||||
(p.taskDescription as string) ?? "",
|
||||
reports ?? [],
|
||||
fragments ?? [],
|
||||
);
|
||||
}
|
||||
case "compare-recall": {
|
||||
return compareRecallPrompt(
|
||||
p.taskName as string,
|
||||
p.standardOutline as string,
|
||||
p.recallOutline as string,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- TTL 清理 ----
|
||||
|
||||
const TTL_MS = 60 * 60 * 1000; // 1 小时
|
||||
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 分钟
|
||||
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - TTL_MS;
|
||||
for (const [id, task] of records) {
|
||||
if (task.status === "done" || task.status === "failed") {
|
||||
const completed = task.completedAt ? Date.parse(task.completedAt) : 0;
|
||||
if (completed > 0 && completed < cutoff) {
|
||||
records.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
Reference in New Issue
Block a user