用 Harness 工程化缓解 LLM 幻觉:Best of N Sampling + LLM as Judge 实战

用 Harness 工程化缓解 LLM 幻觉:Best of N Sampling + LLM as Judge 实战

大模型很强,但如果只让它回答一次,结果往往带有不确定性:有时实现正确,有时边界不完整,有时甚至会一本正经地给出错误代码。

要让 LLM 更稳定地落地到工程场景里,不能只依赖"问得更好",还需要把它放进一个结构化流程里:先生成多个候选结果,再自动评估,最后选择最优答案。这种把生成、评测、择优串起来的工程化流程,可以理解为一种 harness。

harness 原本有"马具"的含义,用在这里很形象:不是让模型自由奔跑,而是用一套流程把模型的能力约束起来,让它在可控的步骤中产出更高质量的结果。

本文通过一个 JavaScript 示例,讲清楚一个简单但很典型的 harness 模式:

  1. Best of N Sampling:并行生成多个候选答案。
  2. LLM as Judge:让大模型充当评分器。
  3. Best Pick:根据评分选择最好的候选结果。

最终要完成的任务很简单:让模型实现一个 JavaScript 数组去重函数。但重点不在数组去重本身,而在如何用工程化流程提升 LLM 输出的可靠性。

一、整体思路:让模型不只回答一次

普通调用方式通常是这样:

js 复制代码
const code = await askLLM("请使用javascript实现一个数组去重函数");
console.log(code);

这种方式的问题是,整个流程只有一次生成机会。如果这一次模型输出不理想,后面就没有自动纠偏机制。

harness 的思路是把一次性调用拆成三步:

txt 复制代码
用户需求
  ↓
生成多个候选代码
  ↓
逐个评分
  ↓
选择最高分候选
  ↓
输出最终结果

这个流程里,大模型会承担两个角色:

  1. 生成者:根据需求生成代码。
  2. 评审者:根据要求给候选代码打分。

这就是 "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
});

这里有两个关键配置:

  1. apiKey:调用模型服务的鉴权凭证。
  2. 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;
}

这个函数做了三件事:

  1. 使用 process.env.MODEL_NAME 指定模型。
  2. 把传入的 prompt 放到 messages 里,作为用户消息发送给模型。
  3. 从响应里取出 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);
}

这个函数接收两个参数:

  1. prompt:用户输入的任务要求。
  2. 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 里有几个明确约束:

  1. 角色:严格的代码评审。
  2. 任务:判断代码是否正确实现数组去重函数。
  3. 输出格式:只返回一个 0-10 的数字评分。
  4. 禁止解释:不要输出额外说明。

这几个约束很重要,因为后续代码会用 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 的价值:它不是某一个模型调用,而是一套流水线。

单独看每个函数都不复杂:

  1. askLLM:负责调用模型。
  2. generateCandidates:负责生成多个候选。
  3. judge:负责给单个候选评分。
  4. evaluateAll:负责批量评分。
  5. pickBest:负责选择最高分结果。
  6. 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 的调用工程化:

  1. 生成和评估解耦。
  2. 单次输出变成多候选输出。
  3. 人工判断变成自动评分。
  4. 最终答案通过明确规则筛选出来。

当我们把 LLM 当成一个可以被编排的能力,而不是一个只能单次问答的工具时,就能开始构建更稳定、更可落地的 AI 应用流程。

相关推荐
neocheng_52243 分钟前
2026 AI 认证选型指南:区分平台、技术、通用 AI 应用三大能力
人工智能
海天一色y43 分钟前
深入解析 DeepSpeedPPOTrainer:基于 PPO 的大规模 RLHF 训练实现
人工智能·强化学习·deepspeed·后训练
05664644 分钟前
python高级——Python 类型提示与 Pydantic
网络·人工智能·windows·python·学习
小女孩真可爱44 分钟前
GPT(3)----------------GQA分组查询注意力机制提速
人工智能·pytorch·gpt·深度学习·大模型
三十而立洋1 小时前
从 0 到 1:我用 VS Code API + Git 命令写了一个"代码统计"插件
javascript
qq_25294131681 小时前
山体滑坡目标检测数据集 | 山体滑坡检测 地质灾害识别 遥感监测 目标检测 YOLO格式
人工智能·yolo·目标检测·计算机视觉·视觉检测·自然灾害·滑坡数据集
tachibana21 小时前
大语言模型基础
数据库·人工智能·语言模型·自然语言处理·大模型·llm
旋转的油纸伞1 小时前
Wukong: Towards a Scaling Law for Large-Scale Recommendation
人工智能·深度学习·神经网络·目标检测·机器学习·自然语言处理·caffe
硅基流动1 小时前
山东铁路基金公司与硅基流动达成战略合作,共建 Token 工厂
人工智能·科技