手写一个 LLM Harness 框架:用工程化手段把大模型幻觉踩在脚下

手写一个 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 接口就能接入。apiKeybaseURL 从环境变量读取,一次注入,全局复用
  • askLLM 是整个框架的原子能力------它不关心调用方是谁、prompt 里写的是"生成代码"还是"打分",只负责把字符串送给 LLM 并把返回文本原样交出。这种纯粹性让它成为全文件的唯一 I/O 边界。
  • res.choices[0].message.content:OpenAI 兼容 API 的响应体中,一次对话请求返回的 choices 数组默认长度为 1,取第一项的 message.content 就是模型输出的完整文本。

为什么抽成函数? 因为 generateCandidatesjudge 最终都要调用 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 三个精巧设计

  1. 角色锚定你是一个严格的代码评审 这十个字决定了评分的气质。换成"你是友好的代码审查者",同样代码得分会偏高。这说明 Judge 的 prompt 本身也是一个需要调参的超参数。
  2. 输出约束只返回一个数字评分,不要解释 ------ 如果模型输出 "我认为这段代码值 8 分,因为...",parseFloat 也能从头提取到 8。但约束越紧,输出越干净,解析越稳定。
  3. 容错兜底parseFloat(res) 解析失败时返回 NaNisNaN(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 框架,但它背后承载的思想比代码量大得多:

  1. Best-of-N Sampling :用"多的"覆盖"随机的"------并行发起 N 个请求,用 Array.from + Promise.all 的组合拳实现最高并发度
  2. LLM as Judge:用 LLM 去评判 LLM------让大模型本身的推理能力替代人工评测,形成自动化质量闭环
  3. Pipeline Orchestration:将生成、评测、择优三个阶段彻底解耦------该并行的敢并行,该串行的能串行,每个组件可独立替换

理解了这 80 行代码和那些藏在 Array.fromPromise.allfor...of 选择背后的工程权衡,再去看 LangChain、AutoGPT、MetaGPT 这些重型框架------你会发现它们的核心骨架,和这段代码如出一辙。

相关推荐
前端开发江鸟1 小时前
我能解释 RAG、MCP 和 Eval,却画不出一条完整的 Agent 链路
人工智能
油丶酸萝卜别吃2 小时前
jquery-ajax.js 说明文档
前端·javascript·jquery
windliang2 小时前
Claude Code 源码分析(九):子 Agent 如何分叉、继续与回到父会话
前端·javascript·面试
wei_shuo2 小时前
KES 云原生部署与弹性扩展:容器化、Kubernetes编排与自动伸缩
后端
一木之林2 小时前
Python.五.(一)--1. 并发编程、异步IO与多进程
后端
feng尘2 小时前
# 彻底搞懂 ReentrantLock 与 tryLock:从秒杀实战到 AQS 独占模式源码剖析
后端
ivywriter2 小时前
【具身智能】物理AI具体指什么,和具身智能是什么关系?
人工智能
new_zhou2 小时前
C++ 项目 AI 协作指南(Windows / MSVC 环境)
c++·人工智能·windows
啷里格啷2 小时前
Linux进程管理完全指南:从基础到云原生编排
后端·架构