用 Harness 工程化缓解 LLM 幻觉:Best of N Sampling + LLM as Judge 实战
大模型很强,但如果只让它回答一次,结果往往带有不确定性:有时实现正确,有时边界不完整,有时甚至会一本正经地给出错误代码。
要让 LLM 更稳定地落地到工程场景里,不能只依赖"问得更好",还需要把它放进一个结构化流程里:先生成多个候选结果,再自动评估,最后选择最优答案。这种把生成、评测、择优串起来的工程化流程,可以理解为一种 harness。
harness 原本有"马具"的含义,用在这里很形象:不是让模型自由奔跑,而是用一套流程把模型的能力约束起来,让它在可控的步骤中产出更高质量的结果。
本文通过一个 JavaScript 示例,讲清楚一个简单但很典型的 harness 模式:
- Best of N Sampling:并行生成多个候选答案。
- LLM as Judge:让大模型充当评分器。
- Best Pick:根据评分选择最好的候选结果。
最终要完成的任务很简单:让模型实现一个 JavaScript 数组去重函数。但重点不在数组去重本身,而在如何用工程化流程提升 LLM 输出的可靠性。
一、整体思路:让模型不只回答一次
普通调用方式通常是这样:
js
const code = await askLLM("请使用javascript实现一个数组去重函数");
console.log(code);
这种方式的问题是,整个流程只有一次生成机会。如果这一次模型输出不理想,后面就没有自动纠偏机制。
harness 的思路是把一次性调用拆成三步:
txt
用户需求
↓
生成多个候选代码
↓
逐个评分
↓
选择最高分候选
↓
输出最终结果
这个流程里,大模型会承担两个角色:
- 生成者:根据需求生成代码。
- 评审者:根据要求给候选代码打分。
这就是 "Best of N Sampling + LLM as Judge" 的组合。
这个思路和 ReAct Agent 的思维框架也有相通之处:不要把模型当成一次性输出工具,而是把它放到一连串结构化步骤中,让模型在不同阶段承担不同职责。本例里没有展开完整的 ReAct 行动链,但已经体现了"生成"和"评估"分离的编排思想。
二、运行环境与依赖
这个示例使用 Node.js ESM 写法,所以入口脚本可以使用 .mjs,并且可以直接使用顶层 await。
依赖主要有两个:
json
{
"dependencies": {
"dotenv": "^17.4.2",
"openai": "^7.5.0"
}
}
dotenv 用来读取环境变量,避免把模型名称、API Key、接口地址直接写死在代码里。
openai SDK 用来调用 OpenAI 兼容接口。即使底层服务不是 OpenAI 官方接口,只要提供兼容的 baseURL,也可以用同一套 SDK 调用。
依赖锁定版本里,openai@7.5.0 对 Node.js 的要求是 >=22.0.0。如果本地 Node 版本过低,模型调用还没执行,依赖本身就可能先出现运行问题。
环境变量可以按下面这种形式配置:
env
MODEL_NAME=qwen-plus
OPENAI_API_KEY=你的_API_KEY
OPENAI_BASE_URL=你的_OpenAI_兼容接口地址
这里需要注意:真实 API Key 不应该写进文章、仓库或公开日志里。它只应该出现在本地环境变量或安全的密钥管理系统中。
三、初始化模型客户端
先引入 SDK 和环境变量配置:
js
import OpenAI from "openai";
import "dotenv/config";
import "dotenv/config" 的作用是自动读取环境变量配置,这样后续就可以通过 process.env 访问配置项。
然后创建客户端:
js
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL
});
这里有两个关键配置:
apiKey:调用模型服务的鉴权凭证。baseURL:OpenAI 兼容接口地址。
这样写的好处是,代码本身不绑定某一个固定模型服务。只要环境变量换掉,就可以切换到另一个兼容接口。
四、封装基础调用:askLLM
所有模型调用最终都会走同一个函数:
js
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;
}
这个函数做了三件事:
- 使用
process.env.MODEL_NAME指定模型。 - 把传入的
prompt放到messages里,作为用户消息发送给模型。 - 从响应里取出
res.choices[0].message.content,作为最终文本结果返回。
这里没有把模型调用逻辑散落在各个函数中,而是统一封装成 askLLM。后面无论是"生成代码",还是"评审代码",本质上都可以复用这个函数。
这也是 harness 流程里一个很重要的设计点:底层模型调用要简单,上层流程编排要清晰。
五、Best of N Sampling:并行生成多个候选答案
接下来是候选答案生成:
js
const generateCandidates = (prompt, n = 3) => {
const tasks = Array.from({ length: n }, () => askLLM(prompt));
return Promise.all(tasks);
}
这个函数接收两个参数:
prompt:用户输入的任务要求。n:要生成多少个候选答案,默认是 3。
核心代码是这一行:
js
const tasks = Array.from({ length: n }, () => askLLM(prompt));
Array.from({ length: n }, ...) 会创建一个长度为 n 的数组,并为每一项执行一次 askLLM(prompt)。
如果 n = 3,就相当于发起三次模型调用:
js
[
askLLM(prompt),
askLLM(prompt),
askLLM(prompt)
]
这些调用会返回 Promise,所以 tasks 是一个 Promise 数组。
然后通过:
js
return Promise.all(tasks);
把多个异步任务并行执行,并等待它们全部完成。
这就是 Best of N Sampling 的核心:不要把希望押在一次生成上,而是生成多个候选结果,让后续评估阶段有选择空间。
六、为什么要生成多个候选?
LLM 的输出通常不是完全固定的。即使是同一个 prompt,多次请求也可能得到不同风格、不同写法、不同完整度的答案。
比如同样是"实现数组去重函数",模型可能生成:
js
function unique(arr) {
return [...new Set(arr)];
}
也可能生成:
js
function unique(arr) {
const result = [];
for (const item of arr) {
if (!result.includes(item)) {
result.push(item);
}
}
return result;
}
这两种写法都可能正确,但质量、简洁性、适用场景会有差异。
Best of N Sampling 的价值在于:先扩大候选范围,再通过评估选择更好的结果。它不是让模型一次就完美,而是给模型多次尝试机会。
七、LLM as Judge:让模型充当评审者
生成多个候选之后,需要对每个候选进行评分。
评分函数如下:
js
async function judge(code) {
const prompt = `
你是一个严格的代码评审,请判断下面的代码是否正确实现"数组去重函数"。
要求:
1. 只返回一个数字评分(0-10)
2. 不要解释
代码:
${code}
`;
const res = await askLLM(prompt);
const score = parseFloat(res);
return isNaN(score) ? 0 : score;
}
这里的关键点是:评审本身也交给 LLM。
模型不再只负责生成答案,还负责判断答案质量。这就是 LLM as Judge。
评审 prompt 里有几个明确约束:
- 角色:严格的代码评审。
- 任务:判断代码是否正确实现数组去重函数。
- 输出格式:只返回一个 0-10 的数字评分。
- 禁止解释:不要输出额外说明。
这几个约束很重要,因为后续代码会用 parseFloat 把模型返回值转换成数字。
如果模型返回:
txt
8
那么:
js
parseFloat("8")
可以得到数字 8。
如果模型没有按要求返回数字,而是返回了一段文字,比如:
txt
这段代码整体正确,我给 8 分。
parseFloat 可能无法得到预期结果。为了避免评分异常,代码里做了兜底:
js
return isNaN(score) ? 0 : score;
只要解析失败,就给 0 分。
这个兜底策略很直接:不符合评分格式的结果,不参与高分竞争。
八、批量评估所有候选代码
单个候选代码的评分由 judge 完成,多个候选代码的评分由 evaluateAll 完成:
js
async function evaluateAll(candidates) {
const results = [];
for (const code of candidates) {
const score = await judge(code);
results.push({ code, score });
}
return results;
}
这个函数接收候选代码数组 candidates,然后逐个评分。
每评分完一个候选,就把代码和分数放进 results:
js
results.push({ code, score });
最终得到的数据结构类似:
js
[
{
code: "function unique(arr) { ... }",
score: 8
},
{
code: "const unique = arr => [...new Set(arr)]",
score: 9
},
{
code: "function unique(arr) { ... }",
score: 6
}
]
这个结构非常适合后续排序,因为每个候选都带着自己的评分。
这里的评估是顺序执行的:
js
for (const code of candidates) {
const score = await judge(code);
}
也就是说,会先评完第一个,再评第二个,再评第三个。这个写法简单直接,也便于理解每一步的执行过程。
九、择优:选择最高分候选
当所有候选都完成评分后,就可以选择最高分结果:
js
function pickBest(results) {
return results.sort((a, b) => b.score - a.score)[0];
}
排序逻辑是:
js
(a, b) => b.score - a.score
如果 b.score 更大,b 会排在前面。也就是说,数组会按分数从高到低排序。
排序完成后,最高分候选就在第一个位置:
js
results[0]
所以直接返回:
js
return results.sort((a, b) => b.score - a.score)[0];
这一步就是 Best Pick:从多个候选答案里挑选评分最高的一个。
十、harness:把生成、评估、择优串成流水线
最核心的流程编排函数是 harness:
js
async function harness(prompt) {
console.log("生成多个候选者...\n");
const candidates = await generateCandidates(prompt, 3);
console.log("候选结果:");
candidates.forEach((c, i) => {
console.log(`\n---Candidate${i + 1} ---\n${c}`);
});
console.log(`\nEvaluate Candidates...\n`);
const evaluated = await evaluateAll(candidates);
console.log("打分结果:");
evaluated.forEach((c, i) => {
console.log(`Candidate${i + 1} -> ${c.score}分`);
});
const best = pickBest(evaluated);
console.log(`\n最佳候选:${best.score}分`);
return best.code;
}
这个函数把前面的能力串成了一个完整闭环:
txt
harness(prompt)
↓
generateCandidates(prompt, 3)
↓
evaluateAll(candidates)
↓
pickBest(evaluated)
↓
return best.code
这就是 harness 的价值:它不是某一个模型调用,而是一套流水线。
单独看每个函数都不复杂:
askLLM:负责调用模型。generateCandidates:负责生成多个候选。judge:负责给单个候选评分。evaluateAll:负责批量评分。pickBest:负责选择最高分结果。harness:负责流程编排。
但组合起来之后,就形成了一个完整的自动化生成与评估系统。
十一、启动任务:生成数组去重函数
最后,只需要调用 harness:
js
const bestCode = await harness("请使用javascript实现一个数组去重函数");
console.log(bestCode);
因为入口是 .mjs,所以这里可以直接使用顶层 await。
完整执行过程大致如下:
txt
生成多个候选者...
候选结果:
---Candidate1 ---
...
---Candidate2 ---
...
---Candidate3 ---
...
Evaluate Candidates...
打分结果:
Candidate1 -> 8分
Candidate2 -> 9分
Candidate3 -> 7分
最佳候选:9分
最后输出的就是评分最高的那段代码。
十二、完整实现
下面是完整实现,保留了核心流程:
js
import OpenAI from "openai";
import "dotenv/config";
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL
});
const generateCandidates = (prompt, n = 3) => {
const tasks = Array.from({ length: n }, () => askLLM(prompt));
return Promise.all(tasks);
}
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;
}
async function evaluateAll(candidates) {
const results = [];
for (const code of candidates) {
const score = await judge(code);
results.push({ code, score });
}
return results;
}
async function judge(code) {
const prompt = `
你是一个严格的代码评审,请判断下面的代码是否正确实现"数组去重函数"。
要求:
1. 只返回一个数字评分(0-10)
2. 不要解释
代码:
${code}
`;
const res = await askLLM(prompt);
const score = parseFloat(res);
return isNaN(score) ? 0 : score;
}
function pickBest(results) {
return results.sort((a, b) => b.score - a.score)[0];
}
async function harness(prompt) {
console.log("生成多个候选者...\n");
const candidates = await generateCandidates(prompt, 3);
console.log("候选结果:");
candidates.forEach((c, i) => {
console.log(`\n---Candidate${i + 1} ---\n${c}`);
});
console.log(`\nEvaluate Candidates...\n`);
const evaluated = await evaluateAll(candidates);
console.log("打分结果:");
evaluated.forEach((c, i) => {
console.log(`Candidate${i + 1} -> ${c.score}分`);
});
const best = pickBest(evaluated);
console.log(`\n最佳候选:${best.score}分`);
return best.code;
}
const bestCode = await harness("请使用javascript实现一个数组去重函数");
console.log(bestCode);
十三、这个 harness 模式解决了什么?
这个模式并不是让 LLM 永远不会出错,而是用工程化方式降低错误结果直接进入最终输出的概率。
它至少做到了三件事:
第一,生成阶段不只依赖一次输出。
js
const candidates = await generateCandidates(prompt, 3);
同一个任务会生成三个候选答案,为后续择优提供空间。
第二,评估阶段自动化。
js
const evaluated = await evaluateAll(candidates);
每个候选答案都会经过一次评分,而不是直接把第一次生成结果交出去。
第三,最终结果有明确选择依据。
js
const best = pickBest(evaluated);
return best.code;
最后输出的是评分最高的候选代码,而不是随机拿一个结果。
这就是用工程化手段缓解 LLM 幻觉的一种方式:不给模型无限自由,而是把模型调用放进结构化流程中,用"生成 - 评估 - 选择"的闭环提升稳定性。
十四、从这个实现能看到的边界
这个 harness 很清晰,但它也有边界。
首先,评审者仍然是 LLM。
js
const res = await askLLM(prompt);
评分结果依然来自模型,因此它不是绝对客观的测试系统。它能提供自动化判断,但不能等同于真实单元测试。
其次,评分依赖 prompt 约束。
txt
只返回一个数字评分(0-10)
不要解释
如果模型没有严格遵守输出格式,代码只能用 parseFloat 尝试解析,并在失败时给 0 分。
最后,当前评审标准主要围绕"是否正确实现数组去重函数"。如果任务变复杂,评审 prompt 也需要写得更清楚,否则评分依据会变得模糊。
这些边界并不影响这个模式的价值,反而说明 harness 的核心不是"相信模型",而是"组织模型"。
十五、总结
Best of N Sampling + LLM as Judge 是一个非常适合入门的 harness 模式。
它的流程很简单:
txt
多次生成候选答案
↓
用 LLM 自动评分
↓
按评分排序
↓
输出最高分结果
对应到实现里,就是:
js
const candidates = await generateCandidates(prompt, 3);
const evaluated = await evaluateAll(candidates);
const best = pickBest(evaluated);
return best.code;
这个模式的重点不是某个复杂算法,而是把 LLM 的调用工程化:
- 生成和评估解耦。
- 单次输出变成多候选输出。
- 人工判断变成自动评分。
- 最终答案通过明确规则筛选出来。
当我们把 LLM 当成一个可以被编排的能力,而不是一个只能单次问答的工具时,就能开始构建更稳定、更可落地的 AI 应用流程。