让大模型"边想边做,做后再想",在生成与评测的闭环中自动筛选最优答案
💬 导语:当 LLM 不再"一本正经地胡说八道"
用过 LLM 的朋友一定有过这样的体验:
你问它一个算法题,它洋洋洒洒写出一段代码,看着逻辑清晰、注释完整,但一跑就报错 ------ 它"幻觉"了一个根本不存在的 API。
更让人头疼的是,同样的 Prompt,每次生成的结果都不一样。有时候惊艳,有时候翻车,完全不可控。
那有没有一种方法,能让 LLM 自己生成、自己打分、自己筛选,最终稳定地产出高质量答案呢?
答案是肯定的。本文要介绍的 Harness 工程,就是这样一个轻量级的 LLM 自优化流水线框架。它只有 73 行代码,却完整实现了:
- ✅ Best of N Sampling ------ 并行生成多份候选答案
- ✅ LLM as Judge ------ 让 LLM 自己当评委打分
- ✅ Harness 抽象 ------ 生成 → 评测 → 择优 三阶段解耦流水线
读完本文,你不仅能掌握这套方法论,还能直接运行它,亲眼见证 LLM "自我进化"的过程。
🧠 核心理念:像驾驭马匹一样驾驭 LLM
Harness 这个词本意是"马具",用来驾驭和引导马匹。借用这个意象,Harness 工程要做的,就是给 LLM 套上一套结构化的"流程马具",让它不再信马由缰,而是在既定轨道上稳定奔跑。
这套框架建立在 ReAct(Reasoning + Acting) 思维框架之上 ------ 让 LLM "边想边做,做后再想",在生成和评测之间形成反馈闭环。
具体落地为以下 三大核心思想:
1️⃣ Best of N Sampling ------ 并行生成多份候选
同一道题,让 LLM 独立生成 3 份答案,利用每次调用的随机性覆盖更多可能性,保证思路多样性。
就像一场考试,让 3 个水平相近的学生独立作答,总有一个人的解法更优。
2️⃣ LLM as Judge ------ 让 LLM 当评委
用 LLM 充当自动化评分器,替代人工评测,实现闭环自动化 ------ LLM 自己产出代码,再自己当评委打分。
这就像让资深专家来批改学生们的试卷,标准统一、效率极高。
3️⃣ Harness 抽象 ------ 三阶段解耦
将 生成、评测、择优 三个阶段解耦为独立模块,像流水线一样串联执行,每个环节可单独替换或升级。
🔁 流水线流程一览
整个 Harness 的执行流程可以用下图清晰地表示:
scss
harness("你的题目")
│
├── [阶段1] 生成阶段(并行3次)
│ askLLM × 3 → Promise.all 等全部完成
│ 输出:["代码A", "代码B", "代码C"]
│
├── [阶段2] 评测阶段(串行逐个打分)
│ judge("代码A") → 8分
│ judge("代码B") → 6分 (等上一个评完才评下一个)
│ judge("代码C") → 9分
│ 输出:[{code:"代码A", score:8}, {code:"代码B", score:6}, {code:"代码C", score:9}]
│
└── [阶段3] 择优阶段
pickBest → 按分数排序,取最高分
输出:{code:"代码C", score:9}
最终返回 best.code → 最优代码
📦 完整代码解析
整份代码围绕 Harness 的 生成 → 评测 → 择优 三阶段流水线展开,共 73 行。下面从源码第一行开始,逐段拆解。
🔧 前置准备:初始化 OpenAI 客户端
js
import OpenAI from "openai"; // 引入 OpenAI SDK,提供 chat.completions.create 等 API
import { config } from "dotenv"; // 引入 dotenv,用来读取 .env 配置文件
config(); // 执行读取,把 .env 里的变量注入到 process.env
这两行 import + 一行 config() 是整个项目的基础设施:
openai包是 "遥控器" ,专门用来和大模型对话dotenv包读取.env文件,把里面的OPENAI_API_KEY、OPENAI_BASE_URL等秘密信息变成代码里可以直接用的变量
js
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY, // 身份认证:你是谁,有没有调用权限
baseURL: process.env.OPENAI_BASE_URL, // 目标地址:请求发到哪里
});
这里创建了一个 client 实例,就是整个项目与 LLM 通信的 唯一入口。
💡 关键设计 :baseURL 指向 https://dashscope.aliyuncs.com/compatible-mode/v1(阿里云百炼),而不是 OpenAI 官网。因为阿里云提供了 "OpenAI 兼容模式" ,代码写法完全不变,只是悄悄把请求转到了阿里云的通义千问模型上。这样做的好处是:
- 🇨🇳 国内访问快,不需要科学上网
- 🔄 代码和 OpenAI 生态完全兼容,随时可以切回 OpenAI
⚙️ 基础能力:askLLM ------ 整个框架的"发动机"
js
const askLLM = async (prompt) => { // 接收一段文字(问题),返回一段文字(回答)
const res = await client.chat.completions.create({ // 调用 OpenAI SDK,发起一次对话请求
model: process.env.MODEL_NAME, // 用哪个模型(如 qwen-plus)
messages: [
{
role: "user", // 角色:用户(你是"提问的人")
content: prompt // 内容:你要问的话
}
]
})
return res.choices[0].message.content; // 把 AI 的回答文本提取出来返回
}
这是整个框架里 被调用次数最多 的函数,所有需要和 LLM 交互的地方都通过它。
逐行拆解:
-
client.chat.completions.create()------ 拆开看就是:遥控器 → 对话模式 → 完整回答 → 发起。这是 OpenAI SDK 的标准调用链,chat表示对话类功能,completions表示完整补全模式,create是执行动作。 -
messages数组里放了对话历史。目前只有一个role: "user"的消息,相当于:"你好,我有一个问题......"。后面如果要改多轮对话,就往这个数组里继续加{ role: "assistant", content: "..." }。 -
res.choices[0].message.content------ 为什么是choices[0]?LLM 返回的数据结构长这样:
jsres = { choices: [ // 答案数组(可以一次返回多个) { index: 0, message: { role: "assistant", // AI 助手 content: "function unique(arr) { return [...new Set(arr)]; }" // ← 实际内容 } } ] }choices永远是数组,因为 API 支持一次返回多个候选答案(通过n参数控制)。我们没有设n,默认只返回 1 个,所以取[0]。
📝 阶段一:生成 ------ generationCandidates
对应 Harness 核心思想:Best of N Sampling ------ 并行生成多份候选,通过随机性增加多样性
js
const generationCandidates = (prompt, n = 3) => { // 默认生成 3 份
const task = Array.from({ length: n }, () => askLLM(prompt)); // 同时创建 n 个 askLLM 调用
return Promise.all(task); // 等所有调用完成,返回全部结果
}
这是 Harness 流水线的 第一阶段。
逐行拆解:
-
Array.from({ length: n }, () => askLLM(prompt)):{ length: n }是一个 "假数组" ------ 只有长度属性,没有实际元素。告诉 JS "我要一个长度为 n 的数组"- 第二个参数
() => askLLM(prompt)是映射函数,给每个空位填上一个askLLM(prompt)调用 - 因为
askLLM是 async 函数,返回值是 Promise,所以task实际上是一个 Promise 数组
当
n = 3时,task等价于:js[ askLLM("请实现数组去重"), // Promise 1 → 代码方案A askLLM("请实现数组去重"), // Promise 2 → 代码方案B askLLM("请实现数组去重"), // Promise 3 → 代码方案C ] -
Promise.all(task)------ 把数组里所有 Promise 一起发射出去,同时等待,等全部完成后返回结果数组。这是并行的关键,3 个请求不互相等待。
🤔 为什么要 3 次独立调用,而不是 n: 3 一次出 3 个?
| 方式 | 请求次数 | 结果多样性 |
|---|---|---|
3 次独立 askLLM |
3 次独立 HTTP 请求,每次都是全新对话 | 高 ------ 像 3 个不同学生独立作答,思路各不相同 |
一次调用设 n: 3 |
1 次 HTTP 请求,共享上下文 | 低 ------ 像同一个学生同时写 3 份答卷,思路趋同 |
Harness 要的不是 "多",而是 "不同思路" 。择优的前提是候选之间有差异,否则选来选去区别不大。
📊 阶段二:评测 ------ judge + evaluateAll
对应 Harness 核心思想:LLM as Judge ------ 让 LLM 自己当评委,替代人工评测
judge ------ 单个评委
js
async function judge(code) {
const prompt = `
你是一个严格的代码评审,请判断下面代码是否正确实现"数组去重函数"
要求:
1. 只返回一个数字评分(0-10)
2. 不要解释代码,只返回评分
代码:
${code}
`
const res = await askLLM(prompt); // 让 LLM 当评委,打分
const score = parseFloat(res); // 把 LLM 返回的字符串转成数字(如 "8" → 8)
return isNaN(score) ? 0 : score; // 如果转失败了(LLM 没好好回答),兜底返回 0
}
这个函数是 LLM as Judge 模式的具体实现:
- 构造评分 Prompt :通过模板字符串
...${code}把候选代码嵌入 prompt 中,告诉 LLM "你是评委,请给这段代码打分"。 - 调用 LLM 评分 :
await askLLM(prompt)把评分任务发给 LLM,等待它返回评分结果。 - 解析分数 :
parseFloat(res)把 LLM 返回的字符串(如"8"、"7.5")转成数字。 - 🛡️ 防御性兜底 :
isNaN(score) ? 0 : score------ LLM 可能不听话,返回"这段代码不错"之类的话而不是纯数字。如果解析失败(NaN),兜底返回 0 分,保证程序不崩溃。当然这份候选也基本被淘汰了。
evaluateAll ------ 批量评测调度
js
async function evaluateAll(candidates) {
const results = [];
for (const code of candidates) { // 逐个遍历每份候选代码
const score = await judge(code); // 等当前这份评完,才评下一份
results.push({ code, score }); // 把 {代码, 分数} 塞进结果数组
}
return results;
}
这是 Harness 流水线的 第二阶段。
🤔 为什么用 for...of + await(串行),而不是 Promise.all(并行)?
- 🔍 评测需要谨慎,逐个打分更稳定
- 🚦 避免同时发 3 个评分请求被 API 限流
- 🐛 串行执行也方便调试,出问题时能知道是哪份代码打分失败
执行后的 results 结构:
js
[
{ code: "代码方案A", score: 8 },
{ code: "代码方案B", score: 6 },
{ code: "代码方案C", score: 9.5 },
]
🏆 阶段三:择优 ------ pickBest
对应 Harness 核心思想:择优筛选 ------ 从评测结果中选出最优
js
function pickBest(results) {
return results.sort((a, b) => b.score - a.score)[0]; // 按分数降序排列,取第一个(最高分)
}
这是 Harness 流水线的 第三阶段,也是最简单的一步。
sort((a, b) => b.score - a.score)------ 降序排序。b - a结果为正值时交换位置,所以分数高的排在前面[0]------ 排序后取数组第一个元素,即最高分的那份- 返回值就是
{ code: "最优代码", score: 最高分 }
🤔 为什么这个函数不加 async?
Array.sort() 是纯 JavaScript 内存操作,不涉及任何网络请求、文件读写等异步操作。如果函数内部没有 await,就不要加 async。一旦加了 async,函数返回值会被自动包一层 Promise,导致调用时拿不到实际数据(best.code 会是 undefined)。
🎯 总调度:harness ------ 三条流水线的总指挥
js
async function harness(prompt) {
// ===== 阶段1:生成 =====
console.log('生成多个候选者....\n');
const candidates = await generationCandidates(prompt, 3); // 并行生成 3 份答案
candidates.forEach((c, i) => { // 遍历打印每份候选,方便查看
console.log(`\n----Candidate ${i + 1}----\n${c}`);
});
// ===== 阶段2:评测 =====
console.log(`\n Evaluate Candidates...\n`);
const evaluated = await evaluateAll(candidates); // 串行评测,逐个打分
// ===== 阶段3:择优 =====
const best = pickBest(evaluated); // 纯同步,取最高分
return best.code; // 只返回最优代码
}
这个函数就是 Harness 框架的灵魂。它不自己干体力活,而是把三件大事分别交给三个专门的函数去做:
| 调用的函数 | 对应的 Harness 阶段 | 核心思想 |
|---|---|---|
generationCandidates(prompt, 3) |
生成 | Best of N Sampling |
evaluateAll(candidates) |
评测 | LLM as Judge |
pickBest(evaluated) |
择优 | 自动筛选 |
这就是 "解耦" 的体现 ------ 每个阶段各司其职,互不干扰。后期要换评测策略、换生成方式,只改对应的函数就行,不用动整个流水线。
js
// 启动!顶层 await(.mjs 文件支持)
const bestCode = await harness("请使用javascript 实现一个数组去重函数");
console.log(bestCode);
最后两行是程序的启动入口。因为 .mjs 文件支持顶层 await,不需要包在 main() 函数里,可以直接在全局作用域调用。
🗺️ 一张图总结全部代码
scss
.env 配置 ──→ client (遥控器) ──→ askLLM (发动机)
│
┌─────────────────────────┼─────────────────────────┐
│ │ │
阶段1:生成 阶段2:评测 阶段3:择优
generationCandidates judge → evaluateAll pickBest
│ │ │
3次独立 askLLM LLM 当评委打分 sort 后取 [0]
Promise.all 并行 for...of 串行 同步排序
│ │ │
["代码A","代码B","代码C"] [{code,score},...] {code,score}
│ │ │
└────────────────────┼─────────────────────────────┘
│
harness() 总调度
│
best.code 最优代码
📋 代码结构速查
| 函数 | 类型 | 负责阶段 | 核心依赖 |
|---|---|---|---|
askLLM |
async | 基础能力 | client.chat.completions.create |
generationCandidates |
返回 Promise | 阶段1:生成 | Array.from + Promise.all |
judge |
async | 阶段2:评测(单个) | askLLM |
evaluateAll |
async | 阶段2:评测(批量) | for...of + judge |
pickBest |
同步 | 阶段3:择优 | Array.sort |
harness |
async | 总调度 | 串联以上全部 |
⚙️ 环境配置
项目目录下创建 .env 文件:
env
# 阿里云百炼 API 密钥(调用阿里云大模型的"钥匙")
OPENAI_API_KEY=sk-ws-xxxxxxxxxxxxx
# 使用的模型名称(阿里云百炼支持的模型)
MODEL_NAME=qwen-plus
# 阿里云百炼 OpenAI 兼容模式地址
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
通过设置
OPENAI_BASE_URL指向阿里云百炼的兼容接口,可以用 OpenAI SDK 的写法调用阿里云的通义千问模型,无需修改代码,只需改变配置。
📦 依赖
| 包名 | 用途 |
|---|---|
openai |
OpenAI SDK,提供 chat.completions.create 等 API |
dotenv |
读取 .env 文件,将配置注入 process.env |
安装命令:
bash
pnpm add openai dotenv
🚀 如何运行
bash
# 1. 安装依赖(在 q1 目录下)
pnpm install
# 2. 确认 .env 配置正确
# OPENAI_API_KEY / MODEL_NAME / OPENAI_BASE_URL
# 3. 运行(因为是 .mjs 文件,直接用 node 运行)
node index.mjs
运行后控制台输出示例:
javascript
生成多个候选者....
----Candidate 1----
function unique(arr) { return [...new Set(arr)]; }
----Candidate 2----
function unique(arr) { return arr.filter((v, i) => arr.indexOf(v) === i); }
----Candidate 3----
function unique(arr) { const map = {}; return arr.filter(v => map[v] ? false : (map[v]=true)); }
Evaluate Candidates...
// 最终输出最高分代码
function unique(arr) { return [...new Set(arr)]; }
🔑 关键技术点
| 知识点 | 说明 |
|---|---|
Array.from({length: n}, fn) |
创建 n 个空位,每个用 fn 填充(这里填的是 askLLM(prompt) 调用) |
Promise.all(task) |
等数组中所有 Promise 都完成,返回全部结果 |
for...of + await |
串行执行异步操作,前一个完成才执行下一个 |
parseFloat + isNaN |
把 LLM 返回的评分字符串转数字,转换失败兜底返回 0 |
Array.sort((a,b) => b - a) |
降序排序,分数最高的排第一 |
| 阿里云兼容模式 | 把 baseURL 指向阿里云,用 OpenAI SDK 写法调通义千问 |
✨ 设计亮点
- 🧩 三阶段解耦:生成、评测、择优各自独立,任一环节可单独替换或升级
- ⚡ 并行生成 + 串行评测:平衡了速度和稳定性
- 🛡️ 防御性编程 :
isNaN(score) ? 0 : score确保 LLM 即使返回非数字内容,程序也不会崩溃 - 🧹 零框架依赖:只依赖 openai + dotenv 两个包,代码简洁易懂
🎬 写在最后:Harness 的下一步想象空间
本文的 Harness 虽然只有 73 行,但它已经是一个 完整可运行的 LLM 自优化流水线。基于这个基础,你可以继续扩展:
- 🔄 多轮迭代:把最优结果反馈给生成阶段,让 LLM 基于评分结果改进答案
- 🎛️ 动态 N 值:根据任务的复杂度动态调整候选数量,简单问题少生成,复杂问题多生成
- 📈 评分校准:引入多个评委模型,综合打分,减少单一评委的偏差
- 🧪 测试用例验证:对于代码类任务,可以实际运行测试用例,用真实结果辅助评分
Harness 的核心思想是通用的 ------ 无论你是做代码生成、文案创作、还是问答系统,这套"生成 → 评测 → 择优"的流水线都能帮你提升 LLM 输出的质量和稳定性。
📌 本文完整代码已开源,欢迎 Star 和 Fork!
作者:Harness 工程实践者
发布时间:2026年8月11日