CareerWise 審查代理升級:讓 AI 自己檢查自己的回答
20 Jul 2026AI 回答看起來頭頭是道,但仔細看可能會發現怪怪的——這就是常見的 AI 幻覺問題。讓同一個模型同時負責「生成」和「驗證」本來就很矛盾,就像寫考卷的人同時兼任閱卷老師,很難發現自己的盲點。解法是把這兩個角色拆開。
CareerWise 是一個職涯諮詢 AI 服務,使用者在上面詢問面試準備、履歷健檢、轉職規劃等問題,由 Summer 代理扮演職涯顧問來回答。這篇文章來探討 CareerWise 如何實作評論家與審查者模式,以及升級過程學到的事。
什麼是評論家與審查者模式?
評論家與審查者(Critic & Reviewer) 的核心是把「生成」和「驗證」兩個功能拆開,而不是讓同一個模型同時生產又同時檢查。
[生成代理] ──→ 產出草稿 ──→ [審查代理]
↑ │
│ 帶著檢查清單:
│ • 事實正確性
│ • 資料來源
│ • 語氣一致性
│ • 安全性
│ │
└──── 打回票 + 修改建議 ←──┘
生成代理負責大膽創作,審查代理帶著嚴格的評分標準進行審查。如果發現問題,審查代理不會幫忙改,而是附上具體建議打回重做,透過來回迭代確保最終輸出的品質。
reflect.ts(src/lib/chains/reflect.ts)是 CareerWise 對評論家與審查者模式的實作——但原本是簡化版。CareerWise 裡的「Summer 代理」和「審查代理」分別是兩次不同的 LLM 呼叫,各自有獨立的 system prompt、獨立的 temperature、獨立的 max_tokens:
// Summer 代理:一次 LLM 呼叫,被設定為 Summer 角色
const summerResponse = await groq.chat.completions.create({
messages: [{ role: "system", content: "你是 Summer — 職涯顧問..." }],
temperature: 0.7,
})
// 審查代理:另一次 LLM 呼叫,被設定為審查員角色
const reviewResult = await groq.chat.completions.create({
messages: [{ role: "system", content: "你是一個回答審查員..." }],
temperature: 0.1,
})
| Summer 代理 | 審查代理 | |
|---|---|---|
| 本質 | 一次 LLM 呼叫 | 另一次 LLM 呼叫 |
| 角色 | 被設定為 Summer(職涯顧問) | 被設定為審查員(品質檢查) |
| system prompt | 「你是 Summer — 一位擁有豐富經驗的…」 | 「你是一個回答審查員…」 |
| temperature | 0.7(較有創造力) | 0.1(較確定性) |
| 職責 | 產出回答 | 挑錯、打回票 |
「Summer」只是該次 LLM 呼叫被賦予的角色名稱(persona),並非一個獨立運行的代理實體。多代理協作指的是這兩次不同的 LLM 呼叫(各自扮演不同角色)之間的迭代協作。
原本的 reflect.ts 在 Summer 代理產出回答後,用審查代理檢查回答品質,發現問題就直接修正:
Summer 產出回答(第一次 LLM 呼叫)
│
▼
reflect.ts:檢查回答(第二次 LLM 呼叫)
檢查清單:
① 事實錯誤或幻覺
② 建議是否具體可行
③ 語氣是否恰當
④ 是否針對使用者具體情況回答
│
├─ OK → 保留原回答
└─ 有問題 → 輸出修正版本取代原回答
這樣做的問題是生成與驗證混在一起,審查代理幫 Summer 代理改答案,而不是讓 Summer 代理自己根據 feedback 改善。
reflect.ts 原本在三個路徑被呼叫:
| 路徑 | 位置 | 觸發條件 |
|---|---|---|
| career chain(medium tier) | career-chain.ts:161 |
每次完成 3-step chain 後 |
| resume chain | resume-chain.ts:160 |
每次完成 3-step chain 後 |
career simple(getReply) |
chat.ts:89,94 |
非串流 career 路徑(LINE Bot) |
串流路徑(getReplyStream)和 planned / complex 路徑不經過 reflect.ts。
升級後變成這樣
這次升級的目標很明確:審查代理只負責挑錯不打修改,把問題打回票讓 Summer 代理自己重寫。
型別定義
先定義審查結果的資料結構:
type ReviewCategory =
| "factual_error" // 事實錯誤或幻覺
| "vague_advice" // 建議空泛不具體
| "tone" // 語氣不恰當
| "not_specific" // 未針對使用者具體情況
| "ungrounded" // 回答無資料來源依據
type ReviewIssue = {
category: ReviewCategory
quote: string // 問題所在的原文片段
description: string // 問題描述
suggestion: string // 建議修改方向
}
type ReviewResult = {
passed: boolean
issues: ReviewIssue[]
}
流程
[Summer 代理] [Review 代理]
│ │
產出回答 ─────────────────────────────────────────→ │
│ │
│ 檢查 5 項清單:
│ ① factual_error
│ ② vague_advice
│ ③ tone
│ ④ not_specific
│ ⑤ ungrounded
│ │
│◀─── { passed: false, │
│ issues: [...] } ────── │
│ │
根據 issues 重寫 ──→ │ │
│ │
產出修正版 ──────────→ │
│ │
│◀─── { passed: true } ────── │
│ │
輸出最終回答
迭代最多 3 次,若 3 次後仍未通過,以最後一次結果輸出(最佳努力)。
審查代理 Prompt
const REVIEW_PROMPT = `你是一個回答審查員。檢查以下職涯顧問的回答。
檢查項目:
1. 事實錯誤或幻覺:是否有與事實或常識矛盾的地方
2. 建議具體性:建議是否具體可行,而非空泛鼓勵
3. 語氣恰當性:語氣是否直接但不失溫暖
4. 針對性:是否根據使用者的具體情況回答
5. 資料來源:回答是否有所依據,而非憑空生成
回傳嚴格符合 JSON schema 的結果,不要有任何額外文字:
{
"passed": true/false,
"issues": [
{
"category": "factual_error" | "vague_advice" | "tone" | "not_specific" | "ungrounded",
"quote": "原文片段",
"description": "問題描述",
"suggestion": "修改建議"
}
]
}
如果全部通過,passed 為 true,issues 為空陣列。`
重寫代理 Prompt
const REWRITE_PROMPT = `你是 Summer — 一位擁有豐富經驗的前端工程師與職涯顧問。
你的回答被審查員指出以下問題,請根據建議修改:
{issues}
原始回答:
{originalResponse}
請輸出修正後的完整回答,不要加任何說明。`
迭代邏輯
const MAX_ITERATIONS = 3
export async function reflectResponse(response: string, userMessage: string): Promise<string> {
let current = response
for (let i = 0; i < MAX_ITERATIONS; i++) {
const review = await reviewAgent(current, userMessage)
if (review.passed) return current
console.log(`[Review] Iteration ${i + 1}: ${review.issues.length} issues found`)
current = await rewriteAgent(current, userMessage, review.issues)
}
return current
}
測試結果
Phase 1:審查代理
測試輸入:怎麼談薪水
原始回答:談薪水要注意幾個點:第一,要了解市場行情。第二,要準備好你的成就。第三,要保持積極的態度。
審查代理成功找出 2 個問題:
{
"passed": false,
"issues": [
{
"category": "vague_advice",
"quote": "要準備好你的成就",
"description": "這個建議太過模糊,沒有提供具體的方法或技巧",
"suggestion": "例如,你可以準備好你的工作成績、業績表或其他相關證據"
},
{
"category": "vague_advice",
"quote": "要保持積極的態度",
"description": "這個建議太過空泛,沒有提供具體的方法或技巧",
"suggestion": "例如,你可以練習如何有效地表達你的需求、或如何處理壓力"
}
]
}
Phase 2:重寫代理
Summer 代理根據審查建議重寫,原本空泛的描述變為具體可執行的方法:
重寫後:
談薪水要注意幾個點:第一,要了解市場行情。了解市場行情可以幫助你
知道自己在薪水方面的實力和競爭力。第二,準備好你的成就。例如,
你可以準備好你的工作成績、業績表或其他相關證據,來證明你的價值
和貢獻。第三,練習如何有效地表達你的需求和期望。
Before: 「要準備好你的成就」→ 空泛
After: 「準備好你的工作成績、業績表或其他相關證據」→ 具體 ✅
Before: 「要保持積極的態度」→ 空泛
After: 「練習如何有效地表達你的需求和期望」→ 具體 ✅
| 項目 | 結果 |
|---|---|
| 審查代理正確找出問題 | ✅ |
| 結構化 JSON 格式正確 | ✅ |
| 建議具體、可執行 | ✅ |
| 重寫代理根據 feedback 產出修正版 | ✅ |
| 修正後回答比原始回答更具體 | ✅ |
迭代邏輯的調整

第一次上線測試「前端工程師面試大概會問什麼」,跑了 3 次 iteration(6 次 LLM 呼叫),回應時間 41 秒:
Server log:
[Review] Iteration 1: 4 issues
[Review] Iteration 2: 3 issues
[Review] Iteration 3: 3 issues
[Review] Max iterations (3) reached, returning last version
POST /api/chat 200 in 41s
| 迭代 | Issues | 結果 |
|---|---|---|
| 1 | 4 | 重寫 |
| 2 | 3 | 改善 1 個,重寫 |
| 3 | 3 | 未再改善,以最後版本輸出 |
觀察:
- 審查代理正確迭代,第二次 iteration 減少了 1 個問題
- 但 3 次 iteration 從未 passed,問題數卡在 3 個
- 回應時間 41s(6 次 LLM 呼叫)
根據測試結果做了以下調整:
| 項目 | 改善前 | 改善後 |
|---|---|---|
| 最大迭代次數 | 3 | 2 |
| 通過標準 | passed === true | passed === true 或 issue ≤ 2 |
| 無改善處理 | 繼續到上限 | 立即退出 |
| LLM 呼叫次數(最壞) | 6 次 | 2-3 次 |
| 回應時間(最壞) | ~41s | ~14s |
改善後的迭代邏輯:
Iteration 1:
review → 4 issues (≥ 3, 未通過)
→ 有改善(prev=∞ > 4)→ 重寫
Iteration 2:
review → 3 issues (≥ 3, 未通過)
→ 有改善(prev=4 > 3)→ 重寫
Iteration 3(不存在,MAX=2):
如果第 2 次無改善 → 立即退出返回
如果第 2 次通過 → 返回
改善後重新測試:
Server log:
[Review] Iteration 1: improved to 4 issues (was Infinity)
[Review] Iteration 2: 4 issues (no improvement), exiting
POST /api/chat 200 in 18s
| 迭代 | Issues | 結果 |
|---|---|---|
| 1 | 4 | 有改善(∞→4)→ 重寫 |
| 2 | 4 | 無改善(4→4)→ 退出 |
| 指標 | 改善前(MAX=3) | 改善後(MAX=2) |
|---|---|---|
| 迭代次數 | 3 | 2 |
| LLM 呼叫次數 | 6 | 3 |
| 回應時間 | 41s | 18s |
| 未通過時行為 | 繼續迭代到上限 | 無改善立即退出 |
回應時間從 41s 降至 18s,減少 56%。品質上沒有因為減少迭代而犧牲——因為第 3 次本來就沒有改善。
注意事項
JSON 輸出方式
審查代理的回傳格式是 JSON,但 CareerWise 沒有用 response_format 參數來強制。原因是 Groq 的 response_format 在某些模型上不支援,而且就算支援,回傳的 JSON 也不保證 100% 符合預期的 schema。在這裡解法是用 prompt 明確要求回傳 JSON,然後在程式端用 try/catch 做保護:
try {
const parsed = JSON.parse(reviewContent)
// 確認必要的欄位都存在
if (typeof parsed.passed !== 'boolean' || !Array.isArray(parsed.issues)) {
throw new Error('Invalid schema')
}
return parsed as ReviewResult
} catch {
// JSON parse 失敗或 schema 不符 → 視為 passed,不阻擋回答
return { passed: true, issues: [] }
}
如果 parse 失敗就回傳 { passed: true, issues: [] },讓回答正常通過。這個設計的考量是:審查代理的目的是提升品質,但不該因為審查環節出錯而讓使用者得不到回答。比起審查失敗擋住回答,放行一個可能不是最好的回答反而是比較好的選擇。
Token 成本
每次迭代有兩次 LLM 呼叫(review + rewrite),各約 1K tokens:
| 環節 | tokens | 說明 |
|---|---|---|
| review 輸入 | ~0.5K | 使用者問題 + Summer 的回答 |
| review 輸出 | ~0.3K | JSON 審查結果 |
| rewrite 輸入 | ~0.5K | 原始回答 + issues |
| rewrite 輸出 | ~0.5K | 修正後的回答 |
| 單次迭代 | ~1.8K | 來回一次的總和 |
最多 2 次迭代,最壞情況約 3.6K tokens。以 Groq 的定價來說成本幾乎可以忽略。
介面相容
reflectResponse 的函數簽名 (function signature) 維持不變:
export async function reflectResponse(
response: string,
userMessage: string
): Promise<string>
三個呼叫端(career-chain.ts、resume-chain.ts、chat.ts)都不需要修改。把改動限制在 reflect.ts 內部,降低上線風險。
總結
這次改完的最大收穫是:回應時間從 41 秒降到 18 秒,減少 56%,而且輸出品質完全沒打折——因為第 3 次迭代本來就沒改善,砍掉剛剛好。
Review → rewrite 迭代迴圈讓 Summer 代理自己根據 feedback 重寫,比審查代理直接代勞來得有品質多了。多代理協作的精神不是把工作集中給一個代理,而是讓每個代理做好自己擅長的事——Summer 代理負責創作,審查代理負責挑錯。