Harness 工程:用工程化手段驾驭 LLM,而不是迷信一次回答

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 形式。apiKeybaseURLmodel 应由环境变量提供,避免写进仓库。

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,而是承认模型输出有不确定性,并用工程化的验证、筛选和反馈机制把这种不确定性控制在业务可以接受的范围内。

相关推荐
Dr.kangder1 小时前
嵌入式面试总结(一)——嵌入式系统实时性
面试·职场和发展·架构·嵌入式·虚拟化
何时梦醒1 小时前
TypeScript 类型系统核心:type 与 interface 全方位深度对比(附实战案例)
前端·面试·typescript
书源2 小时前
AI 能写代码之后,前端工程师的价值在哪里?
前端·面试·程序员
吃饱了得干活2 小时前
Java并发安全:看这一篇就懂了!
java·后端·面试
早点睡9752 小时前
向量检索原理入门:从 ANN 到 HNSW
后端·面试
众人皆醒我独醉2 小时前
Embedding:把词变成向量——AI 理解语义的唯一方式
人工智能·面试·llm
程序员爱钓鱼3 小时前
Go 输入输出详解
后端·面试·go
程序员爱钓鱼3 小时前
Rust 生命周期详解:生命周期标注、约束与真实项目应用
后端·面试·rust
狂师3 小时前
用AI做自动化测试,哪些是真不行,哪些是你不会用?
人工智能·面试·测试