From c27c9fdbf968a1066d0c785211f1d920e30fccbe Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Thu, 27 Aug 2026 06:14:21 -0600 Subject: [PATCH] =?UTF-8?q?fix(=E6=B5=81=E7=A8=8B=E5=9B=BE=E8=AF=84?= =?UTF-8?q?=E5=88=86):=20=E9=98=9F=E5=88=97=E9=87=8D=E8=AF=95=E4=BB=8E?= =?UTF-8?q?=E6=B2=A1=E7=94=9F=E6=95=88=E8=BF=87=E3=80=81AI=20=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E4=B8=8D=E8=AE=BE=E8=B6=85=E6=97=B6=E3=80=81=E7=AD=89?= =?UTF-8?q?=E7=BA=A7=E7=94=B1=E6=A8=A1=E5=9E=8B=E8=87=AA=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 重试是摆设 `flowchartQueue` 配了 `attempts: 3` + 指数退避,但任务开头有一道守卫: if (!row || ![0, 1].includes(row.flowchart.status)) return 而 catch 里第一件事就是把 status 写成 3(FAILED)。于是第 2、3 次尝试进来一看 状态是 3,直接 return、算作成功 —— **实际只跑了一次**。AI 侧的偶发失败(限流、 超时、网络抖动)永远等不到重试,学生看到「评分失败」只能自己重新提交。 改成只有最后一次尝试才落 FAILED,中间几次把状态留在 PROCESSING(1) 让守卫放行。 「是不是最后一次」由 worker 算好传进来:`attemptsMade` 是「此前已失败几次」, 当前这次还没计入,所以判据是 `attemptsMade + 1 >= attempts`。 实测(用没配 AI_KEY 这条必然失败的路径):修复前 t+1s 就落 FAILED、只评一次; 修复后评满 3 次,状态到 t+7s 才落 FAILED。 ## fetch 不设超时 `completeChat` 直接 `fetch`,而 fetch 默认不超时。AI 侧一挂就把 worker 的并发位 (只有 2 个)一直占着,学生那边的按钮也就一直转。加 60 秒超时。 流式调用**不加**:那边超时会把正在推的长回答直接掐断,而客户端断开本来就能收尾。 ## 等级不该由模型说了算 提示词里写死了 S/A/B/C 四档分数区间,但模型偶尔会给出「88 分配 S 级」这种自相 矛盾的结果,甚至直接吐「优秀」。脏值会一路串到等级分布图、等级筛选,以及 「A/S 才把流程图展示给学生」的判断里。改成一律由分数推出等级,模型自报的 grade 不再采信;score 本来就已经 clamp 到 0-100。 Co-Authored-By: Claude Opus 5 --- apps/api/src/flowchart/run.ts | 29 +++++++++++++++++++++++++---- apps/api/src/services/ai.ts | 8 ++++++++ apps/api/src/worker.ts | 6 +++++- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/apps/api/src/flowchart/run.ts b/apps/api/src/flowchart/run.ts index 75b6182..06dcfd7 100644 --- a/apps/api/src/flowchart/run.ts +++ b/apps/api/src/flowchart/run.ts @@ -15,22 +15,38 @@ function evaluationPrompt(problem: typeof schema.problem.$inferSelect) { 题目:${problem.title}\n${problem.description.slice(0, 2000)}` } +/** + * 等级一律由分数推出来,不采信模型自报的 grade。 + * 提示词里写死了这四档,但模型偶尔会给出 88 分配 S 级这种自相矛盾的结果, + * 甚至直接吐「优秀」;脏值会一路串到等级分布图和「A/S 才展示流程图」的判断里。 + */ +function gradeForScore(score: number) { + if (score >= 90) return "S" + if (score >= 80) return "A" + if (score >= 70) return "B" + return "C" +} + function parseEvaluation(value: string) { const block = value.match(/```(?:json)?\s*([\s\S]*?)```/)?.[1] const json = block ?? value.match(/\{[\s\S]*\}/)?.[0] if (!json) throw new Error("AI response did not contain JSON") const data = JSON.parse(json) as Record - if (typeof data.score !== "number" || typeof data.grade !== "string") throw new Error("AI response is missing score or grade") + if (typeof data.score !== "number" || Number.isNaN(data.score)) throw new Error("AI response is missing score") + const score = Math.max(0, Math.min(100, data.score)) return { - score: Math.max(0, Math.min(100, data.score)), - grade: data.grade, + score, + grade: gradeForScore(score), feedback: typeof data.feedback === "string" ? data.feedback : "", suggestions: typeof data.suggestions === "string" ? data.suggestions : "", criteria: data.criteria_details && typeof data.criteria_details === "object" ? data.criteria_details : {}, } } -export async function evaluateFlowchart(job: FlowchartJobData) { +export async function evaluateFlowchart( + job: FlowchartJobData, + { isFinalAttempt = true }: { isFinalAttempt?: boolean } = {}, +) { const [row] = await db.select({ flowchart: schema.flowchartSubmission, problem: schema.problem }).from(schema.flowchartSubmission) .innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)) .where(eq(schema.flowchartSubmission.id, job.submissionId)).limit(1) @@ -69,6 +85,11 @@ export async function evaluateFlowchart(job: FlowchartJobData) { // AI provider 的地址、内部报错就这么进了浏览器。真实原因留在服务端日志里, // 学生只需要知道「失败了,再试一次」;error 字段留空,前端有兜底文案。 console.error(`Failed to evaluate flowchart ${row.flowchart.id}`, error) + // 只有最后一次尝试才落 FAILED。中间几次必须把状态留在 PROCESSING(1): + // 上面那道 `![0, 1].includes(status)` 的守卫会把状态为 3 的任务直接放行返回, + // 一旦提前写成 3,队列配的 attempts: 3 就成了摆设 —— 后两次尝试进来什么都不做 + // 就算成功,AI 侧的偶发失败(限流、超时、网络抖动)永远等不到重试。 + if (!isFinalAttempt) throw error await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id)) await publishFlowchartUpdate(row.flowchart.userId, flowchartUpdateSchema.parse({ type: "flowchart_evaluation_failed", diff --git a/apps/api/src/services/ai.ts b/apps/api/src/services/ai.ts index 9059881..004bc52 100644 --- a/apps/api/src/services/ai.ts +++ b/apps/api/src/services/ai.ts @@ -15,10 +15,18 @@ function requestBody(messages: ChatMessage[], stream: boolean) { } } +/** + * 非流式调用的超时。fetch 默认不超时,AI 侧一挂就会把 worker 的并发位一直占着, + * 学生那边的按钮也就一直转。流式调用不设:那边超时会把正在推的长回答直接掐断, + * 客户端断开本来就能收尾。 + */ +const COMPLETE_TIMEOUT_MS = 60_000 + export async function completeChat(system: string, user: string) { if (!config.aiKey) throw new Error("缺少 AI_KEY") const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), { method: "POST", + signal: AbortSignal.timeout(COMPLETE_TIMEOUT_MS), headers: { "content-type": "application/json", authorization: `Bearer ${config.aiKey}` }, body: JSON.stringify(requestBody([ { role: "system", content: system }, diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index 60e6daa..5592b0c 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -18,7 +18,11 @@ const worker = new Worker( const flowchartWorker = new Worker( flowchartQueueName, - async (job) => evaluateFlowchart(job.data), + // attemptsMade 是「此前已经失败过几次」,当前这次还没计进去, + // 所以最后一次尝试的判据是 attemptsMade + 1 >= attempts + async (job) => evaluateFlowchart(job.data, { + isFinalAttempt: job.attemptsMade + 1 >= (job.opts.attempts ?? 1), + }), { connection: createBlockingRedis(), concurrency: 2 }, )