手写一个 LLM Harness 框架:用工程化手段把大模型幻觉踩在脚下
引言
大模型很强,但有两个痛点始终挥之不去:幻觉 (答非所问或捏造事实)和 落地难(生成内容质量不稳定,无法直接进生产)。Prompt Engineering 救不了这个问题------因为同一句 prompt 问三次,LLM 可能给你三个质量天差地别的答案。
那怎么办?答案是:用工程化的手段来管住大模型的随机性。 这就是 Harness 的核心思想。
本文将通过一个最小可运行的实现,带你理解这套 LLM as Judge + Best-of-N Sampling 的组合拳。
一、什么是 Harness?
sql
┌──────────────────────────────────────────────────────┐
│ HARNESS 流水线 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Generate │ ──► │ Evaluate │ ──► │ Select │ │
│ │ 并行生成N个│ │ LLM自动打分│ │ 择优输出 │ │
│ │ 候选答案 │ │ 0~10分 │ │ 最高分 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ 输入 Prompt ──────────────────────────────► 最佳答案 │
└──────────────────────────────────────────────────────┘
Harness 原意是"马具"------用来驾驭烈马的工具。在这里,它是一套将 LLM 的 生成 、评测 、择优 三阶段串联成闭环的流水线编排框架。像被缰绳驾驭的马一样,大模型在结构化流程中自动产出更高质量的结果。
这也是 React Agent 思维框架 的一个缩影:感知(生成候选)→ 推理(评判打分)→ 行动(择优输出),三个环节解耦、可替换、可观测。
二、核心引擎:askLLM --- 一切对话的原子单位
php
import OpenAI from 'openai';
import { config } from 'dotenv';
config();
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_API_BASE_URL,
});
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;
};
逐行拆解:
import { config } from 'dotenv'→config():加载.env文件中定义的所有环境变量,注入到process.env。这意味着 API Key、模型名、Base URL 全部与代码分离,换环境只改配置文件,不动代码。new OpenAI({...}):这里的baseURL允许接入任何兼容 OpenAI 协议的模型服务------千问、DeepSeek、Gemini,只要服务端实现了/v1/chat/completions接口就能接入。apiKey和baseURL从环境变量读取,一次注入,全局复用。askLLM是整个框架的原子能力------它不关心调用方是谁、prompt 里写的是"生成代码"还是"打分",只负责把字符串送给 LLM 并把返回文本原样交出。这种纯粹性让它成为全文件的唯一 I/O 边界。res.choices[0].message.content:OpenAI 兼容 API 的响应体中,一次对话请求返回的choices数组默认长度为 1,取第一项的message.content就是模型输出的完整文本。
为什么抽成函数? 因为 generateCandidates 和 judge 最终都要调用 LLM,把客户端创建和 API 细节封装在单一函数里,后续加日志、加重试、切模型,只改这一个地方。
三、阶段一:Best-of-N Sampling --- 并行生成多个候选
ini
const generateCandidates = (prompt, n = 3) => {
const tasks = Array.from({ length: n }, () => askLLM(prompt));
return Promise.all(tasks);
};
这是整条流水线的第一站,也是整个框架里并发密度最高的一行代码。
3.1 逐行拆解
第 1 行 --- 函数签名
ini
const generateCandidates = (prompt, n = 3) => {
generateCandidates:用const声明的箭头函数,"生成候选者"这个命名直接对应 Harness 流水线的第一段。prompt:要送给 LLM 的提示词。n = 3:默认参数。不传第二个参数时,自动生成 3 个候选。这个 3 不是拍脑袋定的------太少覆盖不到随机性,太多浪费 token 和时间,3 是成本和覆盖率的经验平衡点。
第 2 行 --- 构建 Promises 数组
ini
const tasks = Array.from({ length: n }, () => askLLM(prompt));
这行是整个框架中并发密度最高的一句,值得拆碎了看。
Array.from() 接收两个参数:
| 参数 | 说明 |
|---|---|
第一个 { length: n } |
类数组对象,只要有 length 属性即可 |
第二个 () => askLLM(prompt) |
map 回调,在构造期间对每个槽位调用一次 |
第一步,{ length: n } 是一个普通对象字面量,只有 length 属性,没有任何数字索引。Array.from 看到它有 length,就创建一个有 n 个槽位的数组,每个位置初始为 undefined:
javascript
// n = 3 时,等价于告诉引擎:
[undefined, undefined, undefined]
第二步,第二个参数是 map 回调 。注意------回调里直接写了 askLLM(prompt),而不是 () => askLLM(prompt) 这种懒执行。因为 Array.from 的第二个参数在数组构造期间立即执行 ------每次迭代时 askLLM(prompt) 被调用并立刻返回一个 Promise,这些 Promise 被依次填入对应槽位:
javascript
// 最终 tasks 变成一个 Promise 数组:
[Promise<pending>, Promise<pending>, Promise<pending>]
这意味着 n 个 HTTP 请求几乎同时发出,之间不存在任何串行等待。
第 3 行 --- 等待全部完成
javascript
return Promise.all(tasks);
Promise.all 接收一个 Promise 数组,返回一个新的 Promise:
- 当所有子 Promise resolve 时,它 resolve,结果是一个数组,顺序与发起顺序一致。
- 如果任何一个 子 Promise reject(网络超时、限流、API 故障),
Promise.all会立即 reject,不等待其余请求。
⚠️ 这里藏着第一个工程权衡:一个候选掉链子,整批都作废。生产环境通常会改用
Promise.allSettled或包装重试逻辑。
3.2 为什么不用其他写法?
scss
// ❌ 错误写法 ------ Array(3) 创建的是稀疏数组,map 会跳过空位!
Array(3).map(() => askLLM(prompt)) // 回调根本不会执行
// ✅ 等价写法 1
Array(3).fill().map(() => askLLM(prompt))
// ✅ 等价写法 2
[...Array(3)].map(() => askLLM(prompt))
// ✅ 当前写法(最佳)------ 一次调用完成创建 + 填充
Array.from({ length: 3 }, () => askLLM(prompt))
Array.from 两步合一的写法最简洁,且语义最精准:"从一个长度规格出发,映射生成一个新数组"。
3.3 为什么需要"同一个问题问 N 次"?(Best-of-N 原理)
大模型每一次生成本质上是一次概率采样(受 temperature 控制,即使 temperature=0 也不保证完全确定性)。这意味着:
vbnet
Prompt: "写一个数组去重函数"
第 1 次生成 → O(n²) 的双层循环去重
第 2 次生成 → 一行 [...new Set(arr)]
第 3 次生成 → 把去重和排序搞混了,写了个 sort
你无法提前知道哪一次运气好。 所以最简单粗暴的策略就是------别赌,全部都要。让大模型的随机性成为优势而非缺陷:不确定性意味着多样性,多样性意味着总有一个答案在正确的方向上。你只需要把"最好的那个"挑出来。
四、Array.from():你需要知道的一切
既然上面 Array.from 承载了如此核心的职责,我们把它彻底讲清楚。
4.1 完整语法
csharp
Array.from(arrayLike, mapFn?, thisArg?)
| 参数 | 必填 | 说明 |
|---|---|---|
arrayLike |
✅ | 类数组对象(有 length)或可迭代对象 |
mapFn |
可选 | 映射函数,对每个元素调用一次,相当于内置 .map() |
thisArg |
可选 | mapFn 中 this 的指向 |
4.2 类数组是什么?
类数组对象不需要有任何数字索引,只需要一个 length 属性:
ini
const arrayLike = { length: 5 };
Array.from(arrayLike);
// → [undefined, undefined, undefined, undefined, undefined]
引擎做的事:读 length → 创建对应长度的数组 → 每个没有值的位置填 undefined。
4.3 mapFn 的执行时机
这是理解 Array.from 和 .map() 区别的关键:
javascript
// Array.from 的 mapFn 在"构造期间"执行
Array.from({ length: 3 }, (_, i) => {
console.log(`构造第 ${i} 个`);
return i * 2;
});
// 控制台依次输出:
// "构造第 0 个"
// "构造第 1 个"
// "构造第 2 个"
// → [0, 2, 4]
// 对比:Array(n).fill().map()------中间多了一个 fill 步骤
Array(3).fill().map((_, i) => i * 2);
// Array(3) → [空位 × 3]
// .fill() → [undefined, undefined, undefined]
// .map(fn) → [0, 2, 4] ← 多了一步遍历
Array.from 的 mapFn 是在数组创建时同步执行 的------它不需要先构造一个全 undefined 的数组再遍历,而是一边分配槽位一边调用回调填充值。
4.4 最常用的 4 种场景
javascript
// ① 生成数字序列(替代 for 循环)
Array.from({ length: 5 }, (_, i) => i); // [0, 1, 2, 3, 4]
Array.from({ length: 5 }, (_, i) => i + 1); // [1, 2, 3, 4, 5]
// ② 类数组转真数组
Array.from(document.querySelectorAll('div')); // NodeList → Array
Array.from(arguments); // arguments → Array
// ③ 字符串拆成字符数组(支持 Unicode 正确处理)
Array.from('hello'); // ['h', 'e', 'l', 'l', 'o']
Array.from('🚀🔥'); // ['🚀', '🔥'] ← 不会把 emoji 切成两半
// ④ 生成 N 个相同的 Promise 并发执行(本文场景)
Array.from({ length: 3 }, () => askLLM(prompt));
💡
Array.from处理 Unicode 时比str.split('')更安全:后者的空字符串分割在高码点字符(emoji、部分中文生僻字)上会得到错误结果。
4.5 为什么这里是它的最佳场景?
Harness 里我们需要的是:几乎同时发出 N 个 HTTP 请求,之间没有任何串行依赖。 Array.from 的 map 回调在构造期间同步调用 N 次 askLLM,N 个 Promise 几乎在同一帧创建并开始飞行。这是 JavaScript 单线程事件循环下能实现的最高并发度------没有任何不必要的中间步骤,一步到位完成"创建数组 + 发起 N 个请求"。
五、阶段二:LLM as Judge --- 让大模型当评委
javascript
async function judge(code) {
const prompt = `
你是一个严格的代码评审,请判断下面代码是否正确实现"数组去重函数"
要求:
- 只返回一个数字评分(0-10)
- 不要解释
代码:
${code}
`;
const res = await askLLM(prompt);
const score = parseFloat(res);
return isNaN(score) ? 0 : score;
}
5.1 这为什么叫"LLM as Judge"?
传统软件工程中,代码评审靠人,测试靠写死的断言。但 LLM 生成的内容往往非确定性 ------这次输出的变量叫 arr,下次叫 list,写法完全不同,没法用固定测试用例覆盖。
让 LLM 给 LLM 打分 :把评判标准写成 prompt,要求模型输出一个 0-10 的数字。精妙之处在于------评判行为本身依然是推理,但有明确的结构约束(只要一个数字)。
5.2 三个精巧设计
- 角色锚定 :
你是一个严格的代码评审这十个字决定了评分的气质。换成"你是友好的代码审查者",同样代码得分会偏高。这说明 Judge 的 prompt 本身也是一个需要调参的超参数。 - 输出约束 :
只返回一个数字评分,不要解释------ 如果模型输出 "我认为这段代码值 8 分,因为...",parseFloat也能从头提取到8。但约束越紧,输出越干净,解析越稳定。 - 容错兜底 :
parseFloat(res)解析失败时返回NaN,isNaN(score) ? 0 : score确保不抛异常。这是一个防御式编程的肌肉记忆------对于"未知质量的 LLM 输出",永远假设它可能不是数字。
ini
async function evaluateAll(candidates) {
const results = [];
for (const code of candidates) {
const score = await judge(code);
results.push({ code, score });
}
return results;
}
注意这里用的是 for...of 串行循环,不是 Promise.all。 为什么生成阶段要并行、评测阶段却要串行?
这就是工程判断力的体现。生成阶段:n 个 API 请求之间没有任何依赖 ------请求 1 的结果不影响请求 2 的 prompt。用并行最大化吞吐量,缩短首字节时间。评测阶段:同样的 LLM API,但有隐式的速率限制 ------大多数 LLM 服务对同一 API Key 有 RPM(每分钟请求数)或并发连接数限制。并行打分可能瞬间触发限流,导致 judge 返回 429 错误,整批评分全部失败。
该快的地方毫不犹豫(生成阶段并行),该慢的地方绝不蛮干(评测阶段串行)。
六、阶段三:择优 --- pickBest 的排序哲学
css
function pickBest(results) {
return results.sort((a, b) => b.score - a.score)[0];
}
时间复杂度 O(n log n) 取最大值,理论上应该用 O(n) 遍历。但评测结果通常只有 3-5 条,sort 的常数开销微乎其微,而"排序后取第一名"这个语序在程序员脑内没有翻译成本------一眼可读。
这种"在小数据集上牺牲算法复杂度换可读性"的决策,是工程师最朴素的务实判断。
七、总装:harness 流水线函数
javascript
async function harness(prompt) {
console.log('生成多个候选者....\n');
// 阶段 1:生成 --- 并行调用 LLM 产出 N 个候选
const candidates = await generateCandidates(prompt, 3);
console.log('候选结果:');
candidates.forEach((c, i) => {
console.log(`\n ---- Candidate ${i + 1} ---- \n ${c}`);
});
console.log('\n Evaluate Candidates....\n');
// 阶段 2:评测 --- LLM 依次给每个候选打分
const evaluated = await evaluateAll(candidates);
console.log('打分结果:', evaluated);
evaluated.forEach((c, i) => {
console.log(`\n ---- Candidate ${i + 1} ---- \n ${c.code} -> ${c.score}`);
});
// 阶段 3:择优 --- 按分数排序取最高分
const best = pickBest(evaluated);
return best.code;
}
// 启动流水线
const bestCode = await harness("请使用 JavaScript 实现一个数组去重函数");
console.log(bestCode);
这张流程图直观展示了整个流水线的执行过程:
scss
harness("写一个数组去重函数")
│
▼
generateCandidates(prompt, 3)
│
┌────┼────┐
▼ ▼ ▼
LLM LLM LLM ← 并行(Promise.all)
│ │ │
▼ ▼ ▼
["用Set...", "用filter...", "用reduce..."]
│
▼
evaluateAll(candidates)
│
┌────┼────┐
▼ ▼ ▼
judge judge judge ← 串行(for...of)
│ │ │
▼ ▼ ▼
[{code, 9}, {code, 6}, {code, 7}]
│
▼
pickBest(evaluated)
│
▼
返回最高分的那段代码
八、Harness 的工程哲学------单一职责
读完整个代码,你会发现每个函数的职责极其窄化:
| 函数 | 职责 | 只关心 |
|---|---|---|
askLLM |
与 LLM 通信 | 模型名、API Key、URL |
generateCandidates |
并行生成 N 个答案 | 并发控制、Promise 编排 |
judge |
给一段代码打分 | prompt 设计和结果解析 |
evaluateAll |
给所有候选打分 | 串行节奏控制 |
pickBest |
从打分结果中选最高分 | 排序逻辑 |
harness |
编排整个流水线 | 阶段顺序和可观测性 |
这是单一职责原则在 AI 工程中的体现。三个阶段的解耦意味着你可以独立升级任何一环:
- 想提升生成质量?把
n从 3 调到 5 - 想换评测模型?把
judge里的 model 从qwen-plus换成更强的推理模型 - 想换择优策略?把
pickBest从"最高分"改为"多数投票" - 想加日志和可观测性?每个阶段后插
console.log(代码里已经做了)
九、写在最后
这篇文章实现了一个不到 80 行的 Harness 框架,但它背后承载的思想比代码量大得多:
- Best-of-N Sampling :用"多的"覆盖"随机的"------并行发起 N 个请求,用
Array.from+Promise.all的组合拳实现最高并发度 - LLM as Judge:用 LLM 去评判 LLM------让大模型本身的推理能力替代人工评测,形成自动化质量闭环
- Pipeline Orchestration:将生成、评测、择优三个阶段彻底解耦------该并行的敢并行,该串行的能串行,每个组件可独立替换
理解了这 80 行代码和那些藏在 Array.from、Promise.all、for...of 选择背后的工程权衡,再去看 LangChain、AutoGPT、MetaGPT 这些重型框架------你会发现它们的核心骨架,和这段代码如出一辙。