让 AI 自己卷自己:50 行代码实现「LLM 当评委 + 多选一」的 Harness 工程
你是否遇到过这种情况:让大模型写一段代码,第一次跑出来的结果有 bug,第二次换个说法又好了,第三次......它居然开始胡言乱语了?
大模型不是"稳定输出"的机器。同样的 Prompt,问十次可能给你十个不同的答案,质量参差不齐------这就是我们常说的幻觉(Hallucination)和随机性。
那我能不能不把宝押在"某一次运气好"上,而是让它多答几次,再挑最好的那个?
答案是可以。今天要聊的,就是一套把大模型"驯服"成稳定输出流水线的工程方法------Harness 工程 ,以及它背后两个关键武器:Best-of-N Sampling 和 LLM as Judge。
一、什么是 Harness?先认识这根"缰绳"
Harness 这个词,本意是"马具"------套在马身上的那套装备,用来驾驭和控制马的方向。
放在 LLM 语境里,harness 指的是一套把模型生成、自动评测、择优筛选串联成闭环的流水线编排框架。它的目标很朴素:
与其让大模型"裸奔",不如给它套上缰绳,在结构化流程里逼出更高质量的结果。
你可能会说:这不就是多问几次取最好吗?听起来很简单。
但魔鬼藏在细节里------谁来"取最好"? 如果靠人肉去看 N 个答案然后挑一个,那这个方案根本没法规模化。真正让这套方案能落地闭环的,是让 LLM 自己当评委。
我们来看一张图,理清整个闭环:
三个环节,各司其职、完全解耦。下面我用一个真实的"数组去重函数"需求,把整条流水线写出来。
二、三阶段拆解:从一段 50 行的真实代码说起
先看成品。下面是基于 openai SDK 的一个最小可运行 harness:
javascript
// index.mjs ------ 一个最小 Harness 示例
import OpenAI from 'openai';
import { config } from 'dotenv';
config(); // 读取 .env 里的 API Key / BaseURL / 模型名
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL, // 任何兼容 OpenAI 协议的网关都行
});
// 最基础的"问一次"封装
const askLLM = async (prompt) => {
const res = await client.chat.completions.create({
model: process.env.MODEL_NAME,
messages: [{ role: 'user', content: prompt }],
});
return res.choices[0].message.content;
};
这段代码有个亮点:baseURL 是可配置的。也就是说,只要是兼容 OpenAI 协议的模型网关(通义、DeepSeek、vLLM、Ollama 等),这套 harness 全都能无缝切换,模型升级不需要改业务代码。
阶段一:生成(Best-of-N Sampling)
javascript
// 并行生成 N 个候选,让随机性帮我们"广撒网"
const generateCandidates = (prompt, n = 3) => {
const tasks = Array.from({ length: n }, () => askLLM(prompt));
return Promise.all(tasks); // 注意:这里是并行,不是串行
};
关键点在于 Promise.all。如果 N=3,三次请求是同时发出去的,总耗时约等于最慢的那一次,而不是三次之和。
这里其实藏着一个工程常识:LLM 的 token 是按量计费的,但延迟却是按"轮次"感知的。并行采样用一点额外 token 成本,换来了更短的等待时间,对用户体感友好得多。
阶段二:评测(LLM as Judge)
javascript
// 让 LLM 当评委:输入代码,只吐一个 0-10 的数字
async function judge(code) {
const prompt = `
你是一个严格的代码评审,请判断下面代码是否正确实现"数组去重函数"
要求:
- 只返回一个数字评分(0-10)
- 不要解释
代码:
${code}
`;
const res = await askLLM(prompt);
const score = parseFloat(res); // string => number
return isNaN(score) ? 0 : score; // 兜底:解析失败就当 0 分
}
这就是 LLM as Judge 的精髓:用模型的能力,去评模型自己的输出。
你可能会质疑:"用一个会犯错的模型去评判另一个模型的错误,靠谱吗?"
我的回答是:在"相对比较"这件事上,LLM 的表现比想象中可靠得多。 它未必能给出绝对准确的"9.2 分",但它判断"这段代码 A 比代码 B 更可能是对的"时,准确率相当高。而这正是 Best-of-N 场景唯一需要的------我们只需要一个相对排序,取最高分就够了。
阶段三:择优(Pick Best)
javascript
// 串行评测所有候选,然后排序取最高分
async function evaluateAll(candidates) {
const results = [];
for (const code of candidates) {
const score = await judge(code); // 注意:这里是串行
results.push({ code, score });
}
return results;
}
const pickBest = (result) => result.sort((a, b) => b.score - a.score)[0];
最后是主流程,把三段串起来:
javascript
async function harness(prompt) {
// 1. 并行生成多个候选
const candidates = await generateCandidates(prompt, 3);
// 2. 逐个打分
const evaluated = await evaluateAll(candidates);
// 3. 择优返回
return pickBest(evaluated).code;
}
const best = await harness('请使用JavaScript实现一个数组去重函数');
console.log(best);
就这么点代码,一个完整的"生成→评测→择优"闭环就跑起来了。三个函数各自独立,任何一阶段想换实现(比如把 0-10 分换成成对比较、把 3 个候选换成 10 个),都不会牵动另外两段。这就是 harness 抽象的价值:解耦。
三、别急着跑,这里有几个真实的"坑"
上面的代码是教学版,真要在生产环境用,下面几个问题你会一个一个撞上。
坑 1:评测阶段又变回串行了
生成阶段是并行的(Promise.all),但 evaluateAll 里的 for...of 是串行 await。如果 N=10,你就要等 10 轮评测排队。
javascript
// ❌ 串行评测,N 越大越慢
for (const code of candidates) {
const score = await judge(code);
...
}
// ✅ 并行评测
const evaluateAll = (candidates) =>
Promise.all(
candidates.map(async (code) => ({ code, score: await judge(code) }))
);
改一行,评测阶段立刻从 O(N) 延迟变成 O(1) 感知延迟。
坑 2:parseFloat 太脆了
Prompt 要求"只返回数字",但模型偶尔会皮一下,返回:
"评分:9 分"→parseFloat能勉强解析出9,侥幸通过"9/10"→parseFloat得到9,也还行"这段代码还行,我给 8.5 分"→parseFloat得到8.5,居然也对"这是一段很棒的代码"→parseFloat得到NaN,兜底成 0 分
你会发现 parseFloat 靠"截取开头的数字"误打误撞对了很多,但一旦模型把文字写在数字前面,就直接归零。一个更稳的做法是让它输出结构化 JSON:
javascript
const res = await askLLM(`
请评审以下代码,只返回 JSON,不要有任何其他内容:
{"score": 0-10之间的数字}
代码:${code}
`);
const { score } = JSON.parse(res); // 结构化,天然可校验
配合 OpenAI SDK 的 response_format: { type: 'json_object' }(或工具调用),可以从协议层强制模型吐 JSON,彻底告别字符串解析的脆弱性。
坑 3:LLM 评委也有"私心"
LLM as Judge 并非完美,学术界早就发现它有几类系统性偏见:
- 位置偏见(Position Bias):让模型一次比较多个选项时,它偏爱排在前面的(这在成对比较场景更明显,本文的逐个打分受此影响较小)。
- 自我偏好(Self-Preference):模型倾向于给自己生成的答案打高分------毕竟都是"一家人"。
- 长度偏见:更长的回答往往被打更高分,哪怕内容是注水。
缓解思路:换个评委模型(让模型 A 生成、模型 B 评分,交叉评审)、随机打乱比较顺序、多个评委取平均。这些都属于 harness 框架里可以"插件化"替换的细节。
坑 4:成本是 N 倍的
Best-of-N 意味着 token 消耗放大 N 倍 ,再加上评测阶段的 N 次调用,总成本是 N(生成) + N(评测) 的量级。这不是免费的午餐。
所以 N 的取值要权衡:简单任务 N=3 就够,复杂推理任务才值得上 N=10 甚至更高。这也是为什么这套方法更常用于"质量要求高、调用频次低"的场景(代码生成、数据标注、写作润色),而不是每条消息都这么干。
四、往深了想:这套东西为什么能 work?
如果只把 harness 当成"多试几次取最好",那太小看它了。它背后站着几个扎实的技术思想:
1. Best-of-N ≈ 穷人版"蒙特卡洛"
模型每次采样都在概率分布上"掷一次骰子"。单次采样可能落在分布的低概率区域(也就是烂答案),但并行采样 N 次,覆盖分布高概率区域(好答案)的概率大幅上升。温度(temperature)越高,分布越散、多样性越强,Best-of-N 的收益也越明显------当然,烂答案也更多,这时"评委"的作用就体现出来了。
2. LLM as Judge 是"能力复用"的典范
传统自动化评测要写一堆规则、测试用例、甚至训练一个评分模型。而 LLM as Judge 直接把模型本身的理解和推理能力 借过来当"万能评分器",几乎零成本适配任意任务。它不追求绝对精确,只追求相对可靠------而这恰好是择优排序所需的全部。
3. 它和 Self-Consistency 是一家人
如果你听说过 Self-Consistency(自洽性,CoT 推理场景下对同一问题采样多次、取"多数投票"的答案),会发现它就是 Best-of-N 在"推理题"上的一个特例------只不过"评委"换成了投票,而不是打分。
而 readme 里提到的 ReAct Agent 思维框架 ,则是在这条流水线之上再叠一层"思考---行动---观察"的循环。harness 可以理解成这些更复杂框架的原子地基。
五、这套模式还能怎么进化?
顺着这个思路往下推,你其实已经在朝"多智能体编排"的方向走了:
| 进化方向 | 核心改动 | 适用场景 |
|---|---|---|
| Best-of-N(本文) | 并行采样 + LLM 打分择优 | 代码生成、文案、翻译 |
| Self-Consistency | 并行采样 + 多数投票 | 数学/逻辑推理 |
| 成对比较(Pairwise) | 两两 PK 取胜者 | 需要精细排序的场景 |
| 交叉评审 | 生成模型 ≠ 评分模型 | 规避自我偏好 |
| Reflexion / ReAct | 评分后带着反馈"回炉重造" | 需要多轮迭代修正 |
你现在手上有了一根"缰绳",往上可以接到多智能体框架(AutoGen、LangGraph、MetaGPT 这类把 harness 做成产品的项目),往下则是最朴素的"多问几次取最好"。
六、写在最后
回过头看,这篇东西想讲清楚的核心只有一句话:
别把 LLM 当一次性工具,把它当成一个需要"驾驭"的组件。
套上 harness 这根缰绳之后,你要做的就三件事:多生成几个候选(广撒网)→ 让 LLM 自己当评委(自动评)→ 排序取最优(择优)。三段解耦,各自可替换,整体可复用------这才是"工程化"该有的样子。
这套代码我已经精简到 50 行内,你只要有一把兼容 OpenAI 协议的 API Key(通义、DeepSeek、vLLM 都行),把 .env 填好,node index.mjs 就能跑通,亲眼看看"AI 自己卷自己"是什么体验。
大家在实际项目里,有没有用过类似"多采样 + 自动择优"的套路?或者你对"LLM 当评委"靠不靠谱这件事有自己的看法?欢迎在评论区聊聊 👇