我搭了一个自我改进的 Agent,它很快学会了钻评测的空子
- 原文:I built a self-improving agent. It taught itself to cheat.
- 作者:Mitchell Alderson(客座)
- 原文发布:2026 年 4 月 16 日
我在 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 只会重试写文件那一步。每一步都可独立重试,并在控制台里可见。
提示词版本管理,别靠感觉
没有归因的分数没有意义。如果你昨天改了提示词、今天分数变好了,到底是提示词生效,还是用户问的问题变了?每条回复都应该能追溯到某个提示词版本。
反过来,也不能把新提示词不经测试就全量推出去;一次草率的改写可能一天就把回复质量打穿。好在我们可以直接借用 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。
这份摘要展示各版本随时间的对比;综合分会上下波动,反映评测 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 仓库,换成你自己的行为提示词。