🐎 从“幻觉”到“可控”:手把手构建一个 LLM 自优化流水线 Harness

让大模型"边想边做,做后再想",在生成与评测的闭环中自动筛选最优答案


💬 导语:当 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_KEYOPENAI_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 交互的地方都通过它。

逐行拆解

  1. client.chat.completions.create() ------ 拆开看就是:遥控器 → 对话模式 → 完整回答 → 发起。这是 OpenAI SDK 的标准调用链,chat 表示对话类功能,completions 表示完整补全模式,create 是执行动作。

  2. messages 数组里放了对话历史。目前只有一个 role: "user" 的消息,相当于:"你好,我有一个问题......"。后面如果要改多轮对话,就往这个数组里继续加 { role: "assistant", content: "..." }

  3. res.choices[0].message.content ------ 为什么是 choices[0]

    LLM 返回的数据结构长这样:

    js 复制代码
    res = {
      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 流水线的 第一阶段

逐行拆解

  1. 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
    ]
  2. 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 模式的具体实现:

  1. 构造评分 Prompt :通过模板字符串 ...${code} 把候选代码嵌入 prompt 中,告诉 LLM "你是评委,请给这段代码打分"。
  2. 调用 LLM 评分await askLLM(prompt) 把评分任务发给 LLM,等待它返回评分结果。
  3. 解析分数parseFloat(res) 把 LLM 返回的字符串(如 "8""7.5")转成数字。
  4. 🛡️ 防御性兜底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日

相关推荐
sunly_1 小时前
React Suspense 用法详解
前端·javascript·react.js
宋哥转AI1 小时前
深入理解 AI Agent · MCP 子系列 #02:MCP Server 开发实战—从工具注册到无状态新规范
人工智能·agent·mcp
一心只读圣贤书1 小时前
AI 辅助前端空状态体验治理:从无数据页面到可行动引导
前端·人工智能
沐土Arvin1 小时前
音频h5录制开发
前端
PedroQue991 小时前
uni-app路由插件化:解锁高效开发新姿势
前端·uni-app
何时梦醒1 小时前
TypeScript 类型系统核心:type 与 interface 全方位深度对比(附实战案例)
前端·面试·typescript
武子康1 小时前
上下文装不下以后:Pi Compaction 怎样压缩历史,又会丢掉什么
人工智能·llm·agent
渣波1 小时前
TS 必考题深度解析:type 与 interface 的终极对决
前端·javascript