From c521451b66d258923079edb1c0248cc66b13dd3b Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 3 Jul 2026 16:53:24 +0800 Subject: [PATCH] =?UTF-8?q?perf(LLM=20=E4=BD=B5=E7=99=BC):=20=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E5=88=86=E6=9E=90=E8=88=87=E8=AA=A4=E5=A0=B1/?= =?UTF-8?q?=E8=A3=9C=E8=A1=8C=E8=99=9F=E8=A3=81=E6=B1=BA=E6=94=B9=E7=82=BA?= =?UTF-8?q?=E4=B8=A6=E8=A1=8C=20sub-agent=EF=BC=88=E9=A0=90=E8=A8=AD?= =?UTF-8?q?=E4=B8=8D=E9=99=90=E4=BD=B5=E7=99=BC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/findings.js | 36 +++++++++++++++++++----------------- src/llm.js | 33 +++++++++++++++++++++++++++++++++ src/main.js | 15 +++++++++++---- 3 files changed, 63 insertions(+), 21 deletions(-) diff --git a/src/findings.js b/src/findings.js index fb5598c..9553d6f 100644 --- a/src/findings.js +++ b/src/findings.js @@ -1,6 +1,6 @@ import fs from 'fs'; import path from 'path'; -import { chatJSON } from './llm.js'; +import { chatJSON, mapWithConcurrency, LLM_CONCURRENCY } from './llm.js'; import { buildAnalysisPrompt, loadRole, buildVerdictPrompt, buildLocateLinePrompt } from './roles.js'; import { FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js'; import { line, ok, warn } from './log.js'; @@ -368,14 +368,14 @@ function extractFileDiff(diff, file) { * 成功則把 location 補成 `檔案:行號`,否則保留原檔名。 */ export async function resolveMissingLineNumbers(findings, diff, deps = {}) { - const { chatFn = chatJSON, getRole = loadRole, maxAttempts = MAX_LOCATE_ATTEMPTS } = deps; - let resolved = 0; - let pending = 0; - for (const f of findings) { - if (findingLine(f.location) != null) continue; // 已有行號 + const { chatFn = chatJSON, getRole = loadRole, maxAttempts = MAX_LOCATE_ATTEMPTS, concurrency = LLM_CONCURRENCY } = deps; + // 只挑「缺行號且有檔名」的 finding;各自以獨立 LLM 子行程並行定位(併發上限見 concurrency)。 + const pending = findings.filter(f => findingLine(f.location) == null + && String(f.location || '').split(',')[0].split(':')[0].trim()); + if (pending.length === 0) return findings; + + const outcomes = await mapWithConcurrency(pending, concurrency, async (f) => { const file = String(f.location || '').split(',')[0].split(':')[0].trim(); - if (!file) continue; - pending += 1; const systemPrompt = buildLocateLinePrompt(getRole(f.role) || { name: f.role }); const userContent = `${JSON.stringify({ file, problem: f.problem, suggestion: f.suggestion })}\n\n--- ${file} Git Diff ---\n${extractFileDiff(diff, file)}`; let located = null; @@ -390,12 +390,13 @@ export async function resolveMissingLineNumbers(findings, diff, deps = {}) { } if (located != null) { f.location = `${file}:${located}`; - resolved += 1; - } else { - warn(`[${f.role}] ${maxAttempts} 次嘗試後仍無法定位行號,保留檔名: ${file}`); + return true; } - } - if (pending > 0) ok(`補行號: ${resolved}/${pending} 筆成功定位`); + warn(`[${f.role}] ${maxAttempts} 次嘗試後仍無法定位行號,保留檔名: ${file}`); + return false; + }); + + ok(`補行號: ${outcomes.filter(Boolean).length}/${pending.length} 筆成功定位`); return findings; } @@ -576,10 +577,11 @@ export async function filterFalsePositivesWithAI(findings, exclusions = [], chat ? `${exclusionContext.prompt}\n規則:若此 finding 與上述任何一類的路徑、角色或描述高度相似,優先視為誤報或不適用。` : ''; - // 每條 finding 各派一個防守方 sub-agent 裁決,多條時平行處理 - const verdicts = await Promise.all( - findings.map(f => judgeFindingIsFalsePositive(f, defender, exclusionHint, chatFn).then(isFP => ({ f, isFP }))), - ); + // 每條 finding 各派一個防守方 sub-agent 裁決;併發上限與其他 LLM 任務共用 LLM_CONCURRENCY(預設不限制)。 + const verdicts = await mapWithConcurrency(findings, LLM_CONCURRENCY, async (f) => ({ + f, + isFP: await judgeFindingIsFalsePositive(f, defender, exclusionHint, chatFn), + })); const kept = verdicts.filter(v => !v.isFP).map(v => v.f); ok(`AI 誤報過濾(防守方${findings.length > 1 ? '平行' : ''}裁決): ${findings.length} -> ${kept.length} 筆`); return kept; diff --git a/src/llm.js b/src/llm.js index e52d42d..b534bdc 100644 --- a/src/llm.js +++ b/src/llm.js @@ -6,6 +6,39 @@ import { getLLMConfig } from './config.js'; import { recordUsage } from './usage.js'; import { line } from './log.js'; +// 每個 LLM CLI 呼叫(角色分析、補行號等)都是一個獨立子行程。預設「不限制」併發(全部同時跑); +// 若機器資源不足或撞到提供者限流,可用 AI_ASSISTANT_CONCURRENCY 設一個正整數當上限。 +// 0 / 未設定 / 非正整數 → 不限制。 +export const LLM_CONCURRENCY = Number(process.env.AI_ASSISTANT_CONCURRENCY) || 0; + +/** + * 對 items 並行執行 async fn(保序回傳),加速多個獨立的 LLM 子行程呼叫。 + * + * limit 為同時執行上限;`limit <= 0`、非數字或大於項目數時「不限制」(全部並行)。 + * fn 需自行處理例外(內部 try/catch);本函式不會因單一項目 reject 而中斷其餘工作。 + * @template T, R + * @param {T[]} items - 要處理的項目。 + * @param {number} limit - 同時執行的上限;<=0/非數字表示不限制。 + * @param {(item: T, index: number) => Promise} fn - 對每個項目執行的 async 函式。 + * @returns {Promise} 與 items 對應(同索引)的結果陣列。 + */ +export async function mapWithConcurrency(items, limit, fn) { + const list = Array.isArray(items) ? items : []; + const results = new Array(list.length); + if (list.length === 0) return results; + const n = Number(limit); + const workers = (!Number.isFinite(n) || n <= 0) ? list.length : Math.min(n, list.length); + let cursor = 0; + async function run() { + while (cursor < list.length) { + const i = cursor++; + results[i] = await fn(list[i], i); + } + } + await Promise.all(Array.from({ length: workers }, run)); + return results; +} + /** * 將既有 system/user prompt 合併成一次 CLI 呼叫用的輸入。 */ diff --git a/src/main.js b/src/main.js index 78a046b..fdc9bfd 100644 --- a/src/main.js +++ b/src/main.js @@ -9,6 +9,7 @@ import { getRunUsage, getRateLimit, fetchAccountQuota, formatUsageStats, formatU import { cloneRepo, commitAndPush, getRepoState } from './git.js'; import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js'; import { runPreflight } from './preflight.js'; +import { mapWithConcurrency, LLM_CONCURRENCY } from './llm.js'; import { section, step, line, input, output, result, warn, error } from './log.js'; const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace'; @@ -120,15 +121,21 @@ async function main() { } catch (e) { warn(`角色介紹 comment 發布失敗(繼續執行): ${e.message}`); } + // 各角色以獨立 LLM 子行程並行分析(併發上限見 LLM_CONCURRENCY),單一角色失敗僅 warn 後跳過。 const newFindings = []; let fulfilledAnalyses = 0; - for (const role of roles) { + const roleResults = await mapWithConcurrency(roles, LLM_CONCURRENCY, async (role) => { try { - const findings = await analyzeWithRole(role, diff); - fulfilledAnalyses += 1; - newFindings.push(...findings); + return await analyzeWithRole(role, diff); } catch (e) { warn(`[${role.name}] 分析失敗(跳過): ${e.message}`); + return null; + } + }); + for (const findings of roleResults) { + if (findings) { + fulfilledAnalyses += 1; + newFindings.push(...findings); } } if (fulfilledAnalyses === 0) {