Harness 工程:用工程化手段驾驭 LLM,而不是迷信一次回答
让 LLM 写一段代码,第一次看起来常常不错。真正把它接进业务后,问题才开始出现:边界条件漏了、返回格式不稳定、代码能解释却跑不起来,同一个 prompt 多跑几次结果还不一样。
这不是模型"偶尔不聪明",而是生成式模型天然带有不确定性。工程上真正要解决的问题不是"怎样写出一条完美 prompt",而是:怎样让不稳定的生成过程进入一个可检查、可重试、可观测的流程。
这类围绕模型建立输入、工具、验证、评测、重试和交付闭环的工程实践,通常可以叫作 Harness 工程。
本文用"生成数组去重函数"为例,搭建一个小型 Harness:并行生成多个候选,先跑确定性测试,再让 LLM 做补充评审,最后选出最优结果。
Best of N Sampling 和 LLM as Judge 是 Harness 中常见的两个模块,不等于 Harness 的全部。真正的 Harness 还需要明确的验收条件、失败处理和可观测性。
一、LLM 的"野马"困境
LLM 在开放式生成上很强,但生产环境会遇到两个难题:
- 幻觉与错误:输出语法看似正确,却不满足真实需求
- 结果波动:同一个输入可能得到不同实现、不同格式和不同质量
如果把 LLM 当作一次性函数调用,系统质量往往取决于"这一轮刚好生成了什么"。Harness 的目标是把这种随机性包进一个更可靠的流程里。
一个实用的思路是:
text
不确定的生成
↓
确定的约束、测试和筛选
↓
可复查的最终结果
二、Harness 到底包含什么?
一个完整 Harness 的形态很多,但通常会包含这些职责:
| 模块 | 解决的问题 |
|---|---|
| 输入规范 | 任务、格式、约束是否明确 |
| 生成策略 | 单次生成、重试、Best of N、多 Agent |
| 工具与执行环境 | 检索、代码运行、测试、沙箱 |
| 验收与评测 | 是否正确、是否合规、是否满足业务目标 |
| 选择与降级 | 多个候选如何取舍,全部失败怎么办 |
| 可观测性 | 记录 prompt、模型、耗时、分数和失败原因 |
本文实现其中最容易上手的一条闭环:
text
生成 N 个候选
↓
确定性测试过滤
↓
LLM Judge 比较剩余候选
↓
选择最优结果,记录过程
三、先定义验收标准,再让模型写代码
如果需求只写"实现数组去重",模型不知道要保留顺序、如何处理对象、是否允许修改原数组。
先把任务收紧:
text
实现 unique(values):
1. 输入是 JavaScript 数组
2. 返回去重后的新数组,保持首次出现的顺序
3. 不修改原数组
4. 使用 SameValueZero 语义处理 NaN
5. 只输出函数代码,不要 Markdown 代码块
这一步比"让模型多写几次"更重要。Harness 不是把随机输出挑得更漂亮,而是先给出能验证的目标。
四、代码骨架:统一封装模型调用
下面示例使用 OpenAI 兼容的 JavaScript SDK 形式。apiKey、baseURL 和 model 应由环境变量提供,避免写进仓库。
js
import OpenAI from "openai";
import "dotenv/config";
const client = new OpenAI({
apiKey: process.env.LLM_API_KEY,
baseURL: process.env.LLM_API_BASE,
});
async function askLLM(prompt) {
const response = await client.chat.completions.create({
model: process.env.LLM_MODEL,
messages: [{ role: "user", content: prompt }],
});
return response.choices[0]?.message?.content?.trim() ?? "";
}
线上项目还应补充超时、重试、速率限制、请求 ID 和模型版本记录。本文先把重心放在生成和验证闭环。
五、第一阶段:Best of N 生成候选
Best of N 的意思是:对同一个任务生成多个候选,而不是只相信第一次输出。
js
async function generateCandidates(prompt, count = 3) {
const tasks = Array.from({ length: count }, () => askLLM(prompt));
const settled = await Promise.allSettled(tasks);
return settled
.filter((item) => item.status === "fulfilled" && item.value)
.map((item) => item.value);
}
这里使用 Promise.allSettled 而不是 Promise.all:某一个请求失败时,其他成功候选仍然可以继续进入后续流程。
多生成几次确实能带来实现差异,但不要把它理解成概率保证。候选来自相同模型和相似 prompt,错误往往是相关的,而不是完全独立的。
六、第二阶段:先做确定性验证
对于"数组去重函数"这种有明确正确答案的任务,自动测试比 LLM 打分更可靠。
js
const testCases = [
{ input: [1, 1, 2], expected: [1, 2] },
{ input: ["a", "b", "a"], expected: ["a", "b"] },
{ input: [NaN, NaN, 1], expected: [NaN, 1] },
];
实际执行候选代码时,不要在主进程里直接 eval 模型输出。LLM 生成的代码本质上是不可信输入,应该放进受限容器、隔离进程或专用代码执行服务,并限制网络、文件系统、CPU、内存和执行时间。
文章中把沙箱执行抽象成一个函数:
js
async function verifyCandidate(code) {
const execution = await sandbox.run({
code,
testCases,
timeoutMs: 1_000,
});
return {
passed: execution.passed,
failures: execution.failures,
};
}
sandbox.run 的具体实现可以是容器、隔离 worker 或受控的远程执行服务,取决于安全要求。关键不在于接口名称,而是不要把不可信代码直接放进业务服务进程。
七、第三阶段:让 LLM 做"补充评委"
测试能判断结果对不对,却不一定能衡量可读性、复杂度、命名和边界处理。通过测试的候选,可以再交给 LLM 做补充评审。
要求 Judge 返回 JSON,比"只返回一个数字"更稳妥,因为程序可以同时记录评分和理由:
js
async function judgeCandidate(code) {
const prompt = `
你是严格的 JavaScript 代码评审。
只返回 JSON:{"score": number, "reason": string}
评分范围 0 到 10,关注可读性、复杂度和边界处理。
代码:
${code}`;
const raw = await askLLM(prompt);
return parseJudgeResult(raw);
}
解析时仍然要兜底,不能假设模型百分之百遵守格式:
js
function parseJudgeResult(raw) {
try {
const value = JSON.parse(raw);
const score = Number(value.score);
if (Number.isFinite(score) && score >= 0 && score <= 10) {
return { score, reason: String(value.reason ?? "") };
}
} catch {
// 格式不合法时,交给默认分数处理
}
return { score: 0, reason: "Judge 返回格式无效" };
}
LLM Judge 也会犯错,甚至会偏好"看起来高级"的代码。因此它适合做软性质量评估,不应该替代测试、类型检查、安全扫描和人工审核。
八、并行评审并择优
测试通过的候选才需要花额外 token 让 Judge 评审:
js
async function evaluateCandidates(candidates) {
const verified = await Promise.all(
candidates.map(async (code) => ({
code,
verification: await verifyCandidate(code),
}))
);
const passed = verified.filter((item) => item.verification.passed);
return Promise.all(
passed.map(async (item) => ({
...item,
judge: await judgeCandidate(item.code),
}))
);
}
小规模示例可以直接并行。候选数量增大后,应加入并发上限,避免触发模型供应商的速率限制,也避免在同一时刻压垮自己的沙箱资源。
选择最高分时,要处理"全部失败"的情况:
js
function pickBest(results) {
return results.reduce(
(best, item) => (!best || item.judge.score > best.judge.score ? item : best),
null
);
}
九、把整个 Harness 串起来
js
async function harness(taskPrompt) {
const candidates = await generateCandidates(taskPrompt, 3);
const evaluated = await evaluateCandidates(candidates);
const best = pickBest(evaluated);
if (!best) {
throw new Error("没有候选通过验证,请重试或转人工处理");
}
return best;
}
const best = await harness("实现 unique(values) 函数");
console.log(best.code, best.judge);
最终返回的不只是代码,还应该保留验证结果、Judge 评分和失败原因。这些数据是后续调优 prompt、替换模型、调整测试集的依据。
十、为什么这套流程更可靠?
| 阶段 | 抵抗的问题 | 主要依据 |
|---|---|---|
| 明确任务约束 | 模型误解需求 | 可验证的输入规范 |
| Best of N | 单次生成质量波动 | 多个候选实现 |
| 沙箱测试 | 语法、功能和边界错误 | 确定性测试用例 |
| LLM Judge | 风格、可读性和方案取舍 | 软性质量评审 |
| 择优与降级 | 全部失败或分数相同 | 明确的选择规则 |
| 日志与评测集 | 线上效果不可解释 | 可回放的过程数据 |
它并不会让幻觉"消失"。更准确的说法是:把模型的错误暴露在更早、更便宜、更容易追踪的环节。
十一、常见误区
1. 多生成几次,就一定更正确
不一定。多个候选可能共享同一种误解。Best of N 的价值是增加候选多样性,真正的正确性还要靠测试、规则和领域知识验证。
2. LLM Judge 可以替代单元测试
不能。Judge 是概率模型,适合评估难以写死的软指标;对可明确断言的行为,单元测试和静态检查更可信。
3. 通过测试的代码就可以直接上线
也不一定。测试覆盖不到安全、性能、依赖许可、业务边界和可维护性。生产流程仍然需要权限控制、审计和必要的人审。
4. 生成代码可以直接 eval
不能。模型输出应视为不可信输入。没有隔离的 eval 等于把代码执行权限交给外部生成内容。
十二、面试怎么回答 Harness 工程?
可以这样回答:
Harness 工程是围绕 LLM 建立可控执行环境和反馈闭环的工程方法。它不只调用模型,还会规定输入输出、提供工具、执行验证、评测候选、处理失败并记录过程。Best of N Sampling 和 LLM as Judge 是其中常见模式:前者生成多个候选缓解单次波动,后者辅助比较难以硬编码的质量指标。但对于代码正确性,应该优先使用确定性测试和隔离执行环境,不能把 LLM Judge 当成唯一裁判。
十三、总结
| 组件 | 职责 | 关键点 |
|---|---|---|
generateCandidates |
生成多个候选 | Promise.allSettled 保留部分成功结果 |
verifyCandidate |
执行确定性验证 | 沙箱执行,禁止直接 eval |
judgeCandidate |
补充软性质量评审 | JSON 输出 + 解析兜底 |
evaluateCandidates |
组织验证与评审 | 先测后评,节省 token |
pickBest |
选择最优候选 | 处理空结果与失败降级 |
harness |
编排完整闭环 | 记录过程,便于回放和优化 |
Harness 工程不是给 LLM 套上一层华丽的 prompt,而是承认模型输出有不确定性,并用工程化的验证、筛选和反馈机制把这种不确定性控制在业务可以接受的范围内。