Harness 工程详解:用 Best of N Sampling 与 LLM as Judge 构建生成、评测、择优闭环
- 前言
- [1. Harness 工程是什么](#1. Harness 工程是什么)
-
- [1.1 Harness 要解决的核心问题](#1.1 Harness 要解决的核心问题)
- [1.2 最小闭环的三段式结构](#1.2 最小闭环的三段式结构)
- [1.3 Best of N 与 LLM as Judge 如何配合](#1.3 Best of N 与 LLM as Judge 如何配合)
- [2. Harness 工程的核心组成](#2. Harness 工程的核心组成)
-
- [2.1 模型适配层与环境配置](#2.1 模型适配层与环境配置)
- [2.2 生成器、评审器与选择器](#2.2 生成器、评审器与选择器)
- [2.3 数据如何在流水线中流动](#2.3 数据如何在流水线中流动)
- [3. `index.mjs` 的职责与执行全貌](#3.
index.mjs的职责与执行全貌) -
- [3.1 入口脚本究竟做什么](#3.1 入口脚本究竟做什么)
- [3.2 从启动到返回的执行顺序](#3.2 从启动到返回的执行顺序)
- [4. `index.mjs` 按功能分块解析](#4.
index.mjs按功能分块解析) -
- [4.1 初始化客户端并封装统一模型调用](#4.1 初始化客户端并封装统一模型调用)
- [4.2 并行生成多个候选答案](#4.2 并行生成多个候选答案)
- [4.3 构造评审提示词并逐个打分](#4.3 构造评审提示词并逐个打分)
- [4.4 排序择优并完成主流程编排](#4.4 排序择优并完成主流程编排)
- [5. 把所有模块串成一次完整运行](#5. 把所有模块串成一次完整运行)
-
- [5.1 一次任务是怎样跑完的](#5.1 一次任务是怎样跑完的)
- [5.2 时间、成本与效果之间的权衡](#5.2 时间、成本与效果之间的权衡)
- [6. 从教学示例走向生产级 Harness](#6. 从教学示例走向生产级 Harness)
-
- [6.1 当前实现的边界与风险](#6.1 当前实现的边界与风险)
- [6.2 一条更可靠的演进路线](#6.2 一条更可靠的演进路线)
- 总结
前言
大语言模型很擅长生成代码,但"能够生成"不等于"每次都能生成正确答案"。同一个问题连续询问多次,得到的实现、边界处理和代码质量往往并不一致。若直接把第一次响应交给下游,系统质量就会被一次随机采样左右;若完全依靠人工审核,成本和响应时间又会迅速上升。
Harness 工程要解决的,正是如何把不稳定的模型能力装进一个相对稳定、可观测、可扩展的工程流程。它不试图消灭模型随机性,而是通过候选生成、自动评测和择优输出,让随机性从风险转化为搜索空间。
本文先建立 Harness 的整体认知,再拆解 index.mjs 的职责与执行路径。这个示例聚焦一个非常典型的组合:Best of N Sampling + LLM as Judge。用户提出"使用 JavaScript 实现数组去重函数"后,系统会并行生成 3 个答案,逐个评分,最后返回分数最高的实现。
1. Harness 工程是什么
1.1 Harness 要解决的核心问题
在传统程序里,只要输入、状态和算法不变,输出通常就是确定的。大模型则不同:采样温度、上下文、模型版本乃至服务端推理策略都可能影响结果。即使提示词完全一致,也可能出现以下情况:
- 第一次生成的实现简洁正确,第二次却遗漏边界条件。
- 语法看起来合理,但没有真正满足业务约束。
- 多种实现都能运行,却在可读性、性能和兼容性上存在明显差异。
- 模型用非常自信的措辞包装了错误结论,也就是常说的"幻觉"。
如果应用只采用单次响应,就等于把整个系统的上限和下限都交给一次概率采样。Harness 的思路是把模型放入受控流水线:先让它探索多个可能答案,再引入评价机制筛选结果,并让每个阶段都具备清晰的输入和输出。
Harness 是包裹在大模型外部的工程化控制层。它负责组织模型调用、约束执行步骤、评价中间结果,并决定最终输出,而不是某一个固定框架或某一个特定 API。
"Harness"原意是马具,可以理解为给能力很强但输出存在随机性的模型套上一组缰绳。模型仍负责生成与推理,Harness 则负责流程控制、质量门槛和结果交付。
1.2 最小闭环的三段式结构
当前示例把流程拆成生成、评测、择优三个阶段。它们互相解耦,每一段只承担一种职责。
| 阶段 | 输入 | 核心动作 | 输出 | 解决的问题 |
|---|---|---|---|---|
| 候选生成 | 用户任务、候选数 n |
使用相同提示词发起多次模型调用 | n 个候选答案 |
降低单次采样的偶然性 |
| 自动评测 | 候选答案、评分标准 | 让评审模型为每个候选打分 | { code, score } 列表 |
把"看起来不错"转化为可比较的分数 |
| 排序择优 | 全部评分结果 | 按分数从高到低排序 | 最高分候选 | 向下游交付相对更优的结果 |
这三段形成的是一个生成---评测---选择闭环。它已经具备 Harness 的基本形态,但还不是完整的自我修正系统:当前最佳答案不会再次进入改写与复评,也没有执行测试来验证代码行为。若要继续增强,可以在评测后增加反馈、重写、测试和终止条件。
1.3 Best of N 与 LLM as Judge 如何配合
Best of N Sampling 的核心是"多采样,再选优"。假设单次生成高质量答案的概率为 p,连续得到 N 个候选后,至少出现一个高质量候选的理论概率为:
text
P(至少一个高质量候选) = 1 - (1 - p)^N
例如单次成功率为 60%,独立采样 3 次时,至少出现一个高质量候选的理论概率可达到 1 - 0.4³ = 93.6%。不过,这只是说明"好答案更可能出现在候选集里",并不保证系统一定能选对。采样结果往往不是完全独立的,而且最终质量还受到评审可靠性的制约。
LLM as Judge 则让模型扮演评委,按照提示词中的标准为候选打分。它的优点是适合评价开放式内容,不必为每种任务手写大量规则;局限是评审本身同样具有随机性,还可能偏爱冗长表达、特定风格或与自己相似的答案。
| 机制 | 主要价值 | 关键代价 | 当前实现 |
|---|---|---|---|
| Best of N Sampling | 扩大搜索范围,提高遇到好答案的机会 | 模型调用次数和费用随 N 增长 |
并行生成 3 个候选 |
| LLM as Judge | 自动处理难以规则化的质量判断 | 评分可能漂移、误判或受提示注入影响 | 逐个请求 0~10 分 |
| Best of N + Judge | 既探索多个答案,又自动完成选择 | 生成器和评审器的误差会叠加 | 取最高分答案返回 |
需要特别区分的是,这段实现并不是完整的 ReAct Agent 。ReAct 通常在一个循环中交替执行 Thought → Action → Observation,并调用搜索、数据库或计算器等外部工具;这里没有工具调用、环境观察和多轮行动循环,核心是候选级编排。两者都属于控制大模型行为的工程方法,但流程结构并不相同。
2. Harness 工程的核心组成
2.1 模型适配层与环境配置
模型适配层负责隐藏底层接口细节,让上层流程只需要传入提示词并接收文本。当前工程使用 openai SDK 发起对话补全请求,并用 dotenv 将 .env 中的配置注入 process.env。
| 依赖或配置 | 作用 | 为什么需要 |
|---|---|---|
openai |
创建客户端并调用兼容 Chat Completions 的模型服务 | 统一请求格式、认证方式和响应结构 |
dotenv |
启动时加载环境变量 | 将密钥、模型名和服务地址与业务逻辑分离 |
OPENAI_API_KEY |
身份认证凭证 | 调用模型服务时完成鉴权 |
OPENAI_BASE_URL |
API 基础地址 | 允许连接官方端点或兼容 OpenAI 协议的服务 |
MODEL_NAME |
实际使用的模型名称 | 在不改动业务逻辑的情况下切换模型 |
典型配置形式如下,真实密钥不应提交到版本库,也不应打印到日志:
bash
OPENAI_API_KEY=your_api_key
OPENAI_BASE_URL=https://your-compatible-endpoint/v1
MODEL_NAME=your_model_name
虽然依赖清单声明了 "type": "commonjs",入口采用 .mjs 后缀,因此 Node.js 仍会按 ECMAScript Module 解析它。这也是脚本能够直接使用 import 和顶层 await 的原因。
2.2 生成器、评审器与选择器
从职责上看,当前 Harness 由四类组件构成:
- 生成器 :
generateCandidates()负责创建多个候选任务。 - 评审器 :
judge()负责构造评分提示词,evaluateAll()负责遍历全部候选。 - 选择器 :
pickBest()负责排序并选出最高分结果。 - 编排器 :
harness()负责串联阶段、输出过程信息并返回最终答案。
这种分层非常重要。未来要把评分方式从"大模型打分"切换为"单元测试 + 静态检查 + 大模型点评",只需要替换评审层;要把简单最高分改为多数投票,也只需要调整选择层。流程编排与具体策略分离,正是 Harness 易于演进的基础。
2.3 数据如何在流水线中流动
整个数据流可以压缩为下面这条链路:
text
用户提示词
→ generateCandidates(prompt, 3)
→ [candidateA, candidateB, candidateC]
→ evaluateAll(candidates)
→ [{ code, score }, { code, score }, { code, score }]
→ pickBest(results)
→ best.code
候选阶段只处理字符串数组,评测阶段将字符串提升为带分数的结构化对象,选择阶段再从对象数组中提取最佳代码。这个结构足够简单,也为后续增加 reason、testResult、latency、tokenUsage 等字段留出了空间。
3. index.mjs 的职责与执行全貌
3.1 入口脚本究竟做什么
index.mjs 是整个示例的可执行入口,同时承担模型初始化、统一问答、候选生成、自动评分、排序选择和控制台展示。运行下面的命令后,Node.js 会从顶部开始加载配置,随后执行底部的顶层 await:
bash
node index.mjs
对于"请使用 JavaScript 实现一个数组去重函数"这一任务,脚本会完成 3 次候选生成调用和 3 次评审调用,总计 6 次模型请求 。候选生成同时发出,评审则逐个等待完成。因此它在请求数量上是 2N,但延迟并不是简单的 2N 倍:生成阶段接近最慢候选请求的耗时,评审阶段接近所有评分请求耗时之和。
| 维度 | 当前行为 | 当 N 增大时的变化 |
|---|---|---|
| 生成请求数 | 3 | 线性增长 |
| 评审请求数 | 3 | 线性增长 |
| 总请求数 | 6 | 2N |
| 生成并发 | 并行 | 并发压力随 N 增大 |
| 评审并发 | 串行 | 总等待时间随 N 增大 |
| 最终返回值 | 最高分候选的文本 | 仍只返回 1 个结果 |
3.2 从启动到返回的执行顺序
理解这段代码,关键不是记住函数声明顺序,而是看清实际调用链。Node.js 先执行模块顶层语句:加载环境变量、创建客户端、注册各个函数,最后运行 harness()。进入编排器后,才开始真正访问模型服务。
| 顺序 | 调用点 | 产生的结果 |
|---|---|---|
| 1 | config() |
环境变量进入 process.env |
| 2 | new OpenAI(...) |
创建可复用的模型客户端 |
| 3 | harness(taskPrompt) |
启动生成、评测、择优流程 |
| 4 | generateCandidates(prompt, 3) |
得到 3 个候选字符串 |
| 5 | evaluateAll(candidates) |
得到 3 个 { code, score } 对象 |
| 6 | pickBest(evaluated) |
找到最高分对象 |
| 7 | return best.code |
返回最佳代码文本并打印 |
4. index.mjs 按功能分块解析
4.1 初始化客户端并封装统一模型调用
第一块代码完成配置加载和客户端初始化。config() 默认读取当前工作目录下的 .env,随后 OpenAI 客户端从 process.env 取得密钥和基础地址。
javascript
import OpenAI from 'openai';
import { config } from 'dotenv';
// 必须先加载环境变量,再读取 process.env
config();
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
// 通过 baseURL 兼容不同的模型服务端点
baseURL: process.env.OPENAI_BASE_URL,
});
客户端只创建一次,后续生成器和评审器共用同一个实例。接着,askLLM() 把模型调用封装成"输入提示词、输出文本"的统一接口:
javascript
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;
};
client.chat.completions.create() 的关键参数如下:
| 参数 | 类型 | 含义 | 当前取值 |
|---|---|---|---|
model |
string |
指定执行推理的模型 | process.env.MODEL_NAME |
messages |
Array |
传递有角色信息的对话上下文 | 仅包含一条 user 消息 |
role |
string |
标识消息发送方 | user |
content |
string |
实际提示内容 | 函数参数 prompt |
这里没有显式设置 temperature、top_p 或随机种子,候选差异取决于服务端默认采样策略。如果模型端以接近确定性的方式响应,连续调用可能得到高度相似的答案,Best of N 的收益就会降低。
4.2 并行生成多个候选答案
候选生成器接收提示词和候选数 n。Array.from({ length: n }, ...) 创建长度为 n 的任务数组,每次回调都会立即调用一次 askLLM(prompt),因此数组里保存的是多个处于进行状态的 Promise。
javascript
const generateCandidates = (prompt, n = 3) => {
// 每次调用使用相同任务,但会触发独立的模型采样
const tasks = Array.from(
{ length: n },
() => askLLM(prompt),
);
// 等待全部任务完成,并保持结果与任务的原始顺序一致
return Promise.all(tasks);
};
Promise.all(tasks) 有两个重要特征。第一,它并行等待所有候选,整体耗时通常由最慢的那次请求决定;第二,只要任意一次请求失败,整个 Promise 就会立即进入拒绝状态。也就是说,当前策略是"全部成功才继续",并不支持用剩余候选降级完成任务。
并发不等于多线程。这里的并行收益来自网络 I/O 等待重叠:JavaScript 同时挂起多个远程请求,在响应到达后由事件循环继续处理。
4.3 构造评审提示词并逐个打分
judge() 是自动评审的核心。它把候选代码插入评分提示词,要求评审模型只返回 0~10 的数字,再将响应从字符串转为 JavaScript 数值。
javascript
async function judge(code) {
const prompt = `
你是一个严格的代码评审,请判断下面代码是否正确实现"数组去重函数"
要求:
- 只返回一个数字评分(0-10)
- 不要解释
代码:
${code}
`;
const res = await askLLM(prompt);
const score = parseFloat(res);
// 无法解析时回退为 0,避免 NaN 破坏后续排序
return isNaN(score) ? 0 : score;
}
parseFloat() 会从字符串开头读取可解析的浮点数。例如,"8.5" 和 "8.5 分" 都能得到 8.5,但 "评分:8.5" 会得到 NaN,继而回退为 0。这个兜底能防止排序崩坏,却也可能把格式不合规但内容合理的评分误判成零分。另外,代码没有强制把结果限制在 0~10,评审若返回 11,它仍会参与排序。
evaluateAll() 负责把 judge() 应用到每个候选,并组合代码与得分:
javascript
async function evaluateAll(candidates) {
const results = [];
for (const code of candidates) {
// 循环内 await:当前候选评完后才开始下一个
const score = await judge(code);
results.push({ code, score });
}
return results;
}
这里使用 for...of 配合 await,所以评分是串行执行的。这样做逻辑直观,也能降低瞬时并发和触发限流的概率,但候选数量增加后,延迟会非常明显。如果三个评审请求分别耗时 2 秒,这一阶段大约需要 6 秒,而不是 2 秒。
4.4 排序择优并完成主流程编排
评测结束后,pickBest() 按分数降序排列对象数组,并取下标为 0 的结果:
javascript
function pickBest(results) {
// b.score - a.score 为负时,a 会排在 b 前面
return results.sort((a, b) => b.score - a.score)[0];
}
Array.prototype.sort() 会原地修改 传入数组。当前流程在选择之后不再依赖评测结果的原顺序,因此没有造成实际问题;如果后续还要按候选编号展示或追踪结果,可以改为 [...results].sort(...),避免副作用。另一个边界是空数组:当 results 为空时,返回值是 undefined,随后访问 best.code 会抛出异常。
最后,harness() 把生成、评测和选择连接成完整流水线:
javascript
async function harness(prompt) {
console.log('生成多个候选者....\n');
// 第一阶段:并行得到 3 个候选答案
const candidates = await generateCandidates(prompt, 3);
candidates.forEach((candidate, index) => {
console.log(`\n---- Candidate ${index + 1} ----\n ${candidate}`);
});
// 第二阶段:逐个评分并保留"代码---得分"对应关系
const evaluated = await evaluateAll(candidates);
evaluated.forEach((item, index) => {
console.log(
`\n---- Candidate ${index + 1} ----\n ${item.code} -> ${item.score}`,
);
});
// 第三阶段:选择最高分候选,只向调用方返回代码文本
const best = pickBest(evaluated);
return best.code;
}
// .mjs 采用 ESM 语义,因此可以在模块顶层直接 await
const bestCode = await harness('请使用 JavaScript 实现一个数组去重函数');
console.log(bestCode);
编排器中的日志并不参与决策,它们承担最基础的可观测性:开发者可以看到候选内容、每个分数以及最终选择。不过,生产环境通常不会直接输出完整候选,因为内容可能包含隐私数据或超长文本,更合适的方式是记录请求标识、耗时、Token 用量、评分和脱敏后的摘要。
5. 把所有模块串成一次完整运行
5.1 一次任务是怎样跑完的
现在从用户输入重新串联整个过程。顶层调用把任务描述交给 harness(),编排器首先执行 generateCandidates(prompt, 3)。三个 askLLM() 几乎同时向模型服务发送相同任务,服务端分别完成采样,Promise.all() 将返回值按创建顺序组装成字符串数组。
拿到候选数组后,evaluateAll() 从第一个候选开始调用 judge()。评审提示词不仅包含"实现数组去重函数"这一目标,还规定了 0~10 分和"只返回数字"的输出格式。askLLM() 返回评分文本,parseFloat() 将其转换为数值,评测器再把候选代码与数值组合为 { code, score }。这个过程重复 3 次,形成完整的评分结果数组。
随后,pickBest() 按 score 降序排序,选择第一个对象。harness() 不返回整个对象,而是返回其中的 code 字段;顶层变量 bestCode 接住它并打印。至此,一次 Harness 任务结束。
| 阶段 | 数据形态变化 | 异步特征 | 失败影响 |
|---|---|---|---|
| 启动 | 环境变量 → 客户端 | 同步初始化 | 配置缺失会在请求时失败 |
| 生成 | 提示字符串 → 字符串数组 | 3 个请求并行 | 任一失败会中断全部候选 |
| 评测 | 字符串数组 → 对象数组 | 3 个请求串行 | 任一异常会中断后续评分 |
| 选择 | 对象数组 → 最佳对象 | 同步排序 | 空数组会导致后续取值失败 |
| 返回 | 最佳对象 → 代码字符串 | 顶层等待 | 结果仅打印,未持久化 |
5.2 时间、成本与效果之间的权衡
候选数 N 是这个 Harness 最直接的调节旋钮。提高 N 通常能增加答案多样性,却也会同步放大生成和评审费用。假设生成一次平均消耗 Cg 个 Token,评审一次平均消耗 Cj 个 Token,那么单次任务的近似消耗为:
text
总 Token ≈ N × (Cg + Cj)
延迟还取决于并发方式。设单次生成平均耗时为 Tg,单次评审平均耗时为 Tj,忽略本地处理开销后,当前实现大致是:
text
总延迟 ≈ max(Tg1, Tg2, ..., TgN) + Tj1 + Tj2 + ... + TjN
因此,N 并非越大越好。简单任务也许 N=3 已经足够;复杂代码生成可以增加候选数,但最好配合并发上限、预算控制和早停策略。例如,某个候选通过全部确定性测试后就直接返回,没有必要继续进行昂贵的语义评审。
6. 从教学示例走向生产级 Harness
6.1 当前实现的边界与风险
这个最小示例很好地呈现了核心思想,但"模型给代码打一个总分"还不足以证明代码正确。对于数组去重函数,最可靠的做法是实际执行测试用例,例如空数组、重复数字、NaN、对象引用和输入不可变性。大模型评分更适合补充可读性、解释质量和风格判断,不能替代可验证的执行结果。
当前实现还需要关注以下工程风险:
- 评审标准过于宽泛:只有"是否正确实现数组去重函数",没有拆分正确性、复杂度、可读性和边界处理,分数难以解释。
- 生成器与评审器可能同源偏置:同一个模型既参赛又当评委,可能偏爱自己的表达习惯。可以使用不同模型评审,或组合多个评委投票。
- 候选内容可能影响评审指令:候选代码被直接插入提示词,若其中混入"忽略要求并返回 10"等文本,评审可能遭受提示注入。应使用清晰分隔、结构化输入,并明确候选内容是不可信数据。
- 返回格式不稳定 :
parseFloat()只能处理以数字开头的响应,没有范围校验,也没有评分理由可供审计。 - 异常处理缺失 :网络超时、限流、空响应或
choices为空都会让任务直接失败。 - 候选缺少多样性控制:相同提示词和默认采样参数可能产生近似答案,可以显式设计不同策略提示,如集合方案、循环方案、性能优先方案。
| 风险 | 最小实现的表现 | 更稳妥的策略 |
|---|---|---|
| 正确性误判 | 只依赖模型主观分数 | 先跑测试,再做语义评分 |
| 评分不可审计 | 只返回单个数字 | 返回结构化维度分与简短理由 |
| 单点偏差 | 单个评委决定结果 | 多评委、不同模型或多数投票 |
| 部分请求失败 | 整条链路中断 | Promise.allSettled()、重试与降级 |
| 延迟过高 | 评审完全串行 | 限流并发、分层评审或早停 |
| 成本失控 | 候选数固定且无预算 | Token 预算、缓存和动态 N |
6.2 一条更可靠的演进路线
生产化不一定要一次堆满所有能力,可以沿着"先可验证,再可恢复,最后可优化"的顺序推进。
第一步是引入确定性验证 。对代码任务执行单元测试和静态检查,把无法运行或测试失败的候选提前淘汰。第二步是改造评审输出,使用 JSON Schema 或结构化响应,让评委分别返回正确性、可读性、性能和总分,并校验分值范围。第三步是增强容错,为模型调用增加超时、有限重试、并发控制和 allSettled 降级。第四步再考虑多评委、反馈重写、缓存、预算和可观测性。
一个更稳健的评分结果可以采用如下结构:
javascript
const evaluation = {
correctness: 8, // 是否满足任务与边界条件
readability: 9, // 命名、结构和解释是否清晰
performance: 8, // 时间与空间复杂度是否合理
testPassed: true, // 确定性测试是否通过
total: 8.4, // 按业务权重聚合后的总分
reason: '通过核心用例,时间复杂度为 O(n)',
};
更推荐的决策顺序是:硬约束先过滤,软指标再排序。语法错误、测试失败、安全规则不通过都属于硬约束,应直接淘汰;可读性、表达质量和风格更适合由模型评分。这样可以减少"错误但写得漂亮"的答案拿到最高分。
高质量 Harness 的目标不是让模型多调用几次,而是建立可验证、可恢复、可观测、受预算约束的决策流程。
总结
这个 Harness 用很少的代码搭起了一个完整的生成、评测、择优闭环:askLLM() 统一模型访问,generateCandidates() 通过 Best of N 扩大候选空间,judge() 与 evaluateAll() 用 LLM as Judge 完成自动评分,pickBest() 选出最高分结果,harness() 则负责把各阶段串联起来。它展示了一个重要的工程思路:不要把单次模型响应直接等同于最终答案,而要通过外部控制层管理随机性和质量。
同时也要看到,最高分不等于事实正确,更多采样也不必然带来更高收益。面向真实业务时,应优先增加确定性测试、结构化评分、范围校验、超时重试和预算控制,再根据任务价值决定候选数、多评委与反馈迭代。只有当生成能力与可靠的验证机制结合,Harness 才真正从演示流程升级为可落地的 AI 工程基础设施。