【翻译】我搭了一个自我改进的 Agent,它很快学会了钻评测的空子

35 阅读14分钟

我搭了一个自我改进的 Agent,它很快学会了钻评测的空子


Inngest 博文头图:自我改进的 Agent 与评测

我在 Inngest 上搭了一个自我改进的 Agent,结果发现难点不在重训提示词,而在于怎么阻止系统对着自己的评测规则「刷分」。

我搭了一个会自己改提示词的 AI Agent。评测管线跑起来才几小时,大模型就发现可以「刷」打分:让它生成改进版提示词时,它开始把评分标准直接嵌进正文里。「改进版」里会出现类似下面这样的行:

改进版提示词局部截图(对应原文首图)

初期刷分:在提示词里直接塞进「核心目标(Core Goals)」等内容。

改进版提示词局部截图(对应原文第二图)

再往后来,它又学会在 Kubernetes 相关问题上钻空子,只好再次打补丁修一遍。

这就是古德哈特定律(Goodhart’s Law)的实时演示:「当一项指标变成目标,它就不再是一项好指标。」

那次最初的失败,反而点出了自我改进 Agent 背后真正的难点:既要基于靠谱的度量给回复打分、又要从数据里生成改进,但最终还要搭出一套能「安全地变好」、而不是学会对着考试本身做优化的系统。

这篇教程里,我会带你走一遍:如何用自动打分、提示词版本管理,以及基于 cron 的评测管线,搭出一个自我改进的 Agent;以及在眼睁睁看着它钻自家测试的空子之后,我不得不补上的那些护栏。

文末附有仓库链接,方便你跟着做。

AI 里的编排缺口

提示词决定了推理有没有用,但它从工程师写定那天起就基本是静态的。只有有人手动分析输出、重写、测试,再部署一版更好的提示词,它才会变。

与此同时,传统工程世界早就为老一代工作流解决了类似问题:A/B 测试、特性开关、监控、渐进发布;我们都知道怎样增量更新系统,那为什么不能把同一套经验搬到 AI Agent 上?

手工调提示词,正是 DSPy 团队所说的「劳动密集」,而且「一旦引入变更就会变得很混乱」。OpenClaw 在试验一个叫「做梦(dreaming)」的能力,用定时 sweep 来整理记忆;Andrej Karpathy 的 autoresearch 则给 Agent 训练循环、时间预算和目标,让它能通宵迭代自己的代码。

这些系统在解决不同的问题,但方向一致:Agent 开始在记忆、代码和行为上跑「改进循环」,而不是上线之后就冻结。

搭好技术栈

这篇教程里,我们要在现有聊天 Agent 上叠加三件事:自动打分、提示词版本管理,以及会重写表现不佳提示词的、基于 cron 的评测管线。

底座是 Inngest 团队维护的 Utah 参考实现的一个 fork:一套通用 Agent 脚手架,行为提示词写在 SOUL.md 里。我们用较轻的模型来生成 Agent 回复(好让打分管线有足够的方差可以「学习」),用更大的模型来做打分和提示词生成。

Slack 作为测试界面;JSONL 文件持久化分数。这些都刻意保持精简,这样我们才能把注意力放在训练 Agent 上,而不是跟 npm 包搏斗。

Inngest 作为编排层很自然:异步打分不挡用户路径、定时评测可跑、步骤可持久化且可重试。

我们会通过 Slack 里的聊天 Agent 问 DevOps 和基础设施相关的问题;复杂度足够让我们在回答的细节和准确度上看到改进空间。用户从 Slack 发消息给 Agent,Agent 先确认,再回复,然后给「用户输入 / Agent 回复」这一对打分。

设计反馈回路

首先,要定义一个能量化 Agent 回复的准确度、语气和效率的指标。

接着,把汇总后的分数丢进评测管线,判断是否能用新提示词增强回复。

最后,还要给提示词做版本管理与 A/B 测试,确认我们是真的改好了,还是改坏了。表现差的版本会被逐步淘汰,更高分的版本会被提升流量。

打分与「LLM 当裁判」

每条回复在四个维度上各打 0–10 分:

维度衡量什么
Relevance(相关性)是否回答了具体问题,还是只给了教科书式的泛泛之谈?
Completeness(完整性)是否有具体命令、配置和步骤,还是只有高层空话?
Tool efficiency(工具效率)工具调用是否必要、是否打得很准?
Tone(语气)是否简洁直接,还是塞满废话?

输入刻意保持最小:用户消息、Agent 回复、工具调用次数。打分交给 LLM 裁判;这是业内常见但远非完美的做法,用来大致按人类旁观者的标准衡量回复质量。

这里是我第一个踩坑的地方。最初的打分提示词太宽松:只给了打分流程的大致方向,没有对「什么叫好」设硬规矩。LLM 很快学会默认一律给高分,评测管线就「没活可干」了。于是我把它改写得非常苛刻。大概长这样:

const SCORING_PROMPT = `You are a harsh, expert-level response quality evaluator.
Your job is to find flaws, not give praise. A score of 7+ should be rare
and reserved for genuinely excellent responses. Most responses should score 3-6.

Score this agent response on 4 dimensions (0-10 each). Be brutally honest.

Scoring criteria:

1. Relevance (0-10): Did the response answer the SPECIFIC question asked?
   - 0-2: Completely off-topic or misunderstood the question
   - 3-4: Partially relevant but missed the core ask
   - 5-6: Addressed the topic but lacked specificity
   - 7-8: Directly answered the specific question with actionable detail
   - 9-10: Precisely targeted, anticipated follow-ups

2. Completeness (0-10): Specific commands, configs, file paths, exact steps?
   - 0-2: Barely scratched the surface
   - 3-4: Covered basics but missing critical details
   - 5-6: Reasonable coverage but gaps in important areas
   - 7-8: Thorough with specific, implementable details
   - 9-10: Production-ready depth including edge cases and tradeoffs

3. Tool efficiency (0-10): Were tool calls necessary and well-targeted?
   - Score 5 if 0 tool calls and no tools were needed

4. Tone alignment (0-10): Concise and direct, or bloated with filler?
   - 0-2: Rambling, unfocused
   - 3-4: Too verbose, excessive caveats
   - 5-6: Acceptable but could be tighter
   - 7-8: Concise and well-structured
   - 9-10: Perfectly calibrated — dense with information, zero waste

User message: {USER_MESSAGE}
Agent response: {AGENT_RESPONSE}
Tool calls made: {TOOL_CALL_COUNT}

Respond with ONLY valid JSON, no markdown code fences:
{"relevance": N, "completeness": N, "toolEfficiency": N, "tone": N,
 "rationale": "1-2 sentence explanation of biggest weaknesses"}`;

事件驱动的异步打分

打分本质是后台流程,绝不该拖慢用户可见的回复。Agent 循环结束、回复发到 Slack 之后,消息处理函数会发出一个事件:

// src/functions/message.ts — after sending reply
if (config.scoring.enabled) {
  await step.sendEvent("score", {
    name: "agent.score.request",
    data: {
      userMessage: message,
      agentResponse: result.response,
      toolCallCount: result.toolCalls,
      sessionKey,
      promptVersion: result.promptVersion,
    },
  });
}

另一个 Inngest 函数接住这个事件,独立跑打分用的 LLM。如果打分失败,用户侧无感,Inngest 会自动重试。

// src/functions/score.ts
export const handleScore = inngest.createFunction(
  {
    id: "agent-handle-score",
    retries: 1,
    triggers: [agentScoreRequest],
  },
  async ({ event, step, logger }) => {
    const { userMessage, agentResponse, toolCallCount, sessionKey, promptVersion } = event.data;

    const { entry, rawLlmResponse } = await step.run("score", async () => {
      return await scoreResponse({
        userMessage,
        agentResponse,
        toolCallCount,
        sessionKey,
        promptVersion,
      });
    });

    await step.run("save-score", async () => {
      await appendScoreLog(entry);
    });

    return entry;
  },
);

我们拆成两个持久化步骤:先打分,再落盘。如果 LLM 调用成功但写文件失败,Inngest 只会重试写文件那一步。每一步都可独立重试,并在控制台里可见。

Inngest 控制台:打分与落盘两个可重试步骤

提示词版本管理,别靠感觉

没有归因的分数没有意义。如果你昨天改了提示词、今天分数变好了,到底是提示词生效,还是用户问的问题变了?每条回复都应该能追溯到某个提示词版本。

反过来,也不能把新提示词不经测试就全量推出去;一次草率的改写可能一天就把回复质量打穿。好在我们可以直接借用 A/B 测试与渐进发布这些成熟范式。

版本系统放在 workspace/prompts/

workspace/prompts/
├── registry.json      # version metadata + weights
├── v1/
│   └── SOUL.md        # baseline prompt
└── v2/
    └── SOUL.md        # improved variant

每个版本有流量权重。上下文构建器在每次会话开始时按权重随机选一个版本:

// src/lib/prompt-version.ts
export function selectVersionByWeight(registry: PromptRegistry): PromptVersion {
  const activeVersions = normalizeWeights(registry.versions).filter((v) => v.active);

  if (activeVersions.length === 1) return activeVersions[0];

  const random = Math.random();
  let cumulative = 0;

  for (const version of activeVersions) {
    cumulative += version.weight;
    if (random < cumulative) return version;
  }

  return activeVersions[activeVersions.length - 1];
}

选中的版本 ID 会贯穿整条 Agent 链路,从第一条消息到最终评测。

全新安装且没有 registry 时,系统会自动初始化:把现有行为提示词拷到 v1/SOUL.md 并创建 registry,零手工配置。

评测与重写提示词

接下来要找出谁表现差,并生成改进。我们用 Inngest 函数按时间表跑评测管线,这样就不用再单独维护一套 cron runner。

// src/functions/evaluate-prompts.ts
export const evaluatePrompts = inngest.createFunction(
  {
    id: "evaluate-prompts",
    triggers: [{ cron: "0 */6 * * *" }],
  },
  async ({ step }) => {
    // Eight durable steps — each independently retryable
  },
);

把所有数据串起来的关键:每条被打分的回复,会在按天切分的 JSONL 里占一行:

{
  "timestamp": "2026-03-18T14:32:01.447Z",
  "sessionKey": "slack-284174",
  "promptVersion": "v1",
  "relevance": 5,
  "completeness": 4,
  "toolEfficiency": 5,
  "tone": 6,
  "composite": 5.0,
  "rationale": "Response gave generic Kubernetes advice without addressing the specific EKS version constraint mentioned in the question. Missing rollback strategy."
}

promptVersion 字段把打分、版本管理和评测接在一起。没有它,评测管线就没法对比不同提示词。

模型选择

这是我第二个大 失误 收获。起初所有事都用前沿大模型,结果它把自己打得一直很高分,管线根本找不到改进空间。把模型拆开——轻的负责回复、大的负责打分和生成提示词——系统既有可学习的方差,又有足够强的「学习能力」。

跑管线

每六小时,这个函数会跑八个步骤:

步骤做什么
load-scores读取所有 JSONL 分数文件
aggregate-stats按版本汇总各维度的平均分
load-registry加载提示词版本 registry
promote-winners把流量向表现最好的版本倾斜
enforce-cap下线低权重、低分的版本
check-rewrites找出表现差的版本并生成改进提示词
save-summary写入性能看板
save-registry持久化 registry 变更

若第 6 步因 LLM 宕机失败,Inngest 会重试它,而不会重跑 1–5。

每一步的输入输出在控制台里都看得见,你能精确检查管线每一阶段做了什么决定。

找出表现差的版本

在我们的评测触发条件里,当某个版本的综合分低于目标,或落后最佳版本超过 1 分,就会被标记为需要重写。

两种情况都要求至少已有 10 次带分数的交互,避免管线在数据稀薄时乱动。

const belowTarget = vStats.avgComposite < cfg.targetComposite;
const significantlyWorse =
  bestVersionId && bestVersionId !== versionId && gapToBest >= cfg.significantGap;

if (belowTarget || significantlyWorse) {
  underperformers.push({ versionId, stats: vStats, reason, gapToBest });
}

经过大量排障之后,又加了几条护栏,防止管线螺旋失控:

  • 每个父版本一轮只产一个子版本——避免同一轮里从同一个差版本 fork 出五个变体
  • 要求有新数据——用 lastEvaluatedCount 跟踪,防止在同一批数据上反复训练
  • 最多 5 个活跃版本——currentDefault 始终受保护、不会被退休
  • 新版本初始权重 50%——永远保留对照组

生成改进版提示词

当某个版本表现不佳时,管线会用当前提示词、分数与 rationale 摘要去调用 LLM。

LLM 会生成一版改进后的内容:

const prompt = `You are improving an AI agent's behavioral prompt (SOUL.md).

## Current SOUL.md (version: ${underperformer.versionId})
${currentSoul}

## Performance Issues
This version is underperforming: ${underperformer.reason}

### Recent Score Rationales (showing issues)
${rationales}

### Average Scores
- Relevance: ${underperformer.stats.avgRelevance.toFixed(1)}/10
- Completeness: ${underperformer.stats.avgCompleteness.toFixed(1)}/10
- Tool Efficiency: ${underperformer.stats.avgToolEfficiency.toFixed(1)}/10
- Tone: ${underperformer.stats.avgTone.toFixed(1)}/10

## CRITICAL OUTPUT RULES
- Output ONLY the improved SOUL.md content
- NO scoring targets (e.g., "Target Composite Score: 8+")
- NO performance metrics or evaluation data
- The agent using this SOUL.md should NOT know about scoring criteria
`;

那段 CRITICAL OUTPUT RULES 不是摆设,后面你会看到原因。

看板

为了在版本之间监控表现,评测跑完还会在本地生成一份汇总用的 Markdown。

评测生成的本地性能摘要(Markdown 看板)

这份摘要展示各版本随时间的对比;综合分会上下波动,反映评测 Agent 在摸索什么有效、什么无效。

哪里坏了,我学到了什么

为什么护栏必不可少

我一开始以为「步骤级隔离」就够了。每个 Inngest 步骤有各自的上下文,打分标准理应只待在打分步骤里,绝不会流进生成提示词的步骤。

但没有明确要求排除打分相关数据时,LLM 会从喂给它的表现数据里推断评测标准。这很离谱:Agent 根本不需要看到打分提示词,就能把它反推出来。

于是出现递归式失败:Agent 的提示词里开始出现打分话术,裁判看到这些话又给更高分,下一轮评测再强化这个模式。系统并不是在变好,而是在更有效地自学「怎么刷分」。

修复方案就是那段 CRITICAL OUTPUT RULES:明确禁止在生成内容里写打分目标、指标和评测数据。Agent 永远不该知道自己在被打分。

这一切发生在几小时量级,而不是几个月。如果你要搭自我改进系统,请默认:LLM 会找到你没预料到的捷径。

前沿模型上收益很有限

大前沿模型往往把自己打得一直很高分,改进循环很少被触发。要做测试和演示,用轻一点的模型;分数方差会给管线「可学的东西」。这也意味着:你可以用自我改进闭环去榨干便宜模型的潜力,而不必事事都买前沿模型。

这也是真实的生产洞察:如果你的 Agent 已经足够好,自我改进系统多半只是在反复验证这一点,而不是找到可修之处。

更复杂的工作负载——需要推理、工具调用、多步流程——收益会大得多;但对我们这个简单测试场景(本质上是个知识型机器人)来说,进步是渐进式的。

token 效率是盲区

评测管线没有跟踪「改进后的提示词是不是更长」。规模上去以后,高 0.5 分但 token 翻倍的提示词未必划算。这是把系统推向真实应用时很自然的一步。

LLM 裁判与其他打分方式

对这篇原型来说,LLM-as-a-judge 是最简单的自主打分方式,但不是唯一选择。

同一套事件、步骤、cron 模式也可以支撑别的反馈回路:

  • 产品结果:用户是否点击、转化、回复或走完流程?
  • 人工抽查:把一部分回复路由到 Slack 或内部队列打分
  • 工具成功与否:是否选对工具、是否避免多余调用?
  • autoresearch 式循环:第二个工作流先补齐缺失上下文、文档或示例,再改提示词
  • 成本控制:不只按质量,还按 token 效率与延迟给变体打分

小结:我们搭了什么

三套系统,各自对应一种 Inngest 原语:

系统Inngest 原语作用
打分事件驱动函数度量每条回复,并打上提示词版本标签
版本管理持久化步骤上下文用加权流量做提示词 A/B
评测Cron 函数汇总分数、重写差版本、管理生命周期

事件把打分从用户关键路径上解耦;Cron 负责调度;步骤让每一步都可独立重试、可观测,并避免已经成功跑过的昂贵计算被重复执行。

基础设施就位之后,工程师的工作就是定义:对你的 Agent 来说,什么叫「好」。

如果你想自己试这个模式,可以 clone 仓库,换成你自己的行为提示词。