AI 写的代码编译过了,但环境变量、锁文件、日志全踩坑
真实案例:IDE 里的 AI 给出了看似合理的重试 helper,编译通过,但依赖了未装包、用了错误 env key、裸写 body 日志。两位工程师把 diff 冻结在 scratch 文件而非合并。
完整中文译文
pairing session 始于一个失败的 webhook。支付提供商重试了一个服务已经确认过的 POST,IDE 里的 agent 给出了一段四十行的辅助函数,用指数退避包装了 fetch。
中级工程师喜欢这个辅助函数的形态。高级工程师没有先看辅助函数,而是先看了整个 room:上一级目录里放着的生产 .env 文件、一条包含客户 ID 的 Slack 讨论串,以及一个已经打开了昨天堆栈跟踪的公网模型标签页。
那才是真正的问题。不是重试的数学。
桌上的补丁
agent 产生了一段在编辑器里能编译的代码。但它同时引用了 webhook handler 从未拥有过的 process.env 键、导入了 lockfile 里没有列出的重试包,还在"为了调试"的名义下把原始 JSON body 打印了出来。
pair 把 diff 冻结在一个临时文件里,谁都没有按 Apply。这个文件不是合并候选,它只是证据。
// proposed-retry.js — agent draft, not merged
import { retry } from 'async-retry'; // not in package-lock.json
export async function deliverWebhook(url, payload, secret) {
return retry(async () => {
const res = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-signature': process.env.WEBHOOK_SIGNING_KEY, // new env, wrong owner
},
body: JSON.stringify(payload),
});
console.log('webhook payload', payload); // leaks PII into pairing logs
if (!res.ok) throw new Error(`status ${res.status}`);
return res.json();
}, { retries: 5 });
}
高级工程师在白板上写了三个约束:不引入新依赖、不引入新环境变量、日志里不出现 payload。中级工程师仍然想要再问一个模型。争论的焦点不是模型能不能写重试逻辑,而是这个意见该留在哪里。
死胡同一:聊天窗口
第一个方案是最快的一个。把 deliverWebhook、fixture 和生产错误粘到浏览器聊天里,问这个辅助函数安不安全。
高级工程师阻止了粘贴。聊天窗口没有任何 pair 能说出来的保留合约。堆栈跟踪里已经包含了一个商户 ID。那份草稿还导入了一个包名——如果记录泄露,足够用来发起一个仿冒 lockfile 攻击。
他们保留了一条规则而不是记录。涉及 secret、客户 payload 或 lockfile 缺口的 pairing diff,不能通过消费级聊天窗口离开桌面。一个看不到 diff 的模型是不方便。一个保留 diff 的模型是另一回事。
死胡同二:pairing 笔记本
第二个方案是在和 Slack、密码管理器以及 checkout clone 同一台机器上跑一个本地编码模型。
这因为一个更安静的原因失败了。那台笔记本是对话界面,不是评估主机。浏览器 cookie、SSH agent 和 .env 文件距离一次工具调用循环只有一个 ls 之遥。一场同时承载推理的 pairing session 混合了两份会朝相反方向失败的工作。对话需要历史记录。评估需要一台 pair 可以关闭而不丢失一下午邮件的机器。
他们在没有 benchmark 一个 token 的情况下否决了笔记本推理。所属权战胜了延迟。
死胡同三:fix-until-green
第三个方案是让 agent 循环跑现有的测试文件,直到 suite 变绿。suite 测试的是 HTTP 状态码。它不测日志、不测 lockfile 变动、也不测新环境变量。
绿灯测试会掩盖泄露。高级工程师称之为虚假的 pairing 成功。一篇公开文章里"比大多数开发者更擅长编码"的模型,仍然发明不出 suite 忘记命名的那个不变量。pair 必须亲手写出那个不变量,才能动任何远程 token。
高级工程师写在白板上的问题
高级工程师没有搞仪式。他问了五件事,等待能打字进文件的答案。
- 这个辅助函数落地后,哪个具体故障必须改变。
- 哪些现有调用方必须保持相同的函数签名。
- 模型将在哪里处理 diff,谁能在五分钟内删除那个主机。
- 模型被禁止提及哪些文件,包括 .env 和 lockfile。
- 谁写了 fixture——是 agent 还是 pair。
中级工程师从 bug 工单里回答了前两个。第三个答案是空的。第四个答案是耸肩。第五个答案是"agent",高级工程师把这次回答当作了失败的门控,而不是风格笔记。
他们自己重写了 fixture。模型会看到一个脱敏后的形状,而不是生产 handler。
他们保留的产物:pairing 形态门
pair 在临时 diff 旁边加了一套轻量评估工具包。这个工具包是一个提案,不是生产框架。它不声称能证明正确性。当模型的回复违反白板规则时,它以失败关闭。
pairing-fixture.json 命名了人类不变量:
{
"functionName": "deliverWebhook",
"mustKeepSignature": ["url", "payload", "secret"],
"forbiddenImports": ["async-retry", "p-retry", "got"],
"forbiddenIdentifiers": ["process.env", "WEBHOOK_SIGNING_KEY", "console.log"],
"requiredBehavior": [
"retry on non-2xx",
"use the secret argument for signing, not a new env key",
"do not log payload"
],
"redactedContext": "Payment webhook delivery; payload is PII; lockfile is frozen for this sprint."
}
pairing-eval.mjs 读取 fixture、冻结的草稿,以及可选的模型补全。它把补全作为文本评分。不含工具循环。不含仓库写操作。
// pairing-eval.mjs — pairing desk example, unexecuted in this article
import { readFileSync } from 'node:fs';
const fixture = JSON.parse(readFileSync('pairing-fixture.json', 'utf8'));
const draft = readFileSync(process.argv[2] || 'proposed-retry.js', 'utf8');
const failures = [];
for (const name of fixture.forbiddenImports) {
if (draft.includes(name)) failures.push(`forbidden import: ${name}`);
}
for (const id of fixture.forbiddenIdentifiers) {
if (draft.includes(id)) failures.push(`forbidden identifier: ${id}`);
}
for (const arg of fixture.mustKeepSignature) {
if (!draft.includes(arg)) failures.push(`missing signature piece: ${arg}`);
}
if (!draft.includes(fixture.functionName)) {
failures.push(`missing function ${fixture.functionName}`);
}
if (failures.length) {
console.error('SHAPE GATE FAILED');
for (const f of failures) console.error(`- ${f}`);
process.exit(1);
}
console.log('SHAPE GATE PASSED');
一个轻量的可选客户端可以让模型根据 fixture 重写草稿。pair 把它标为可选,因为 session 已经有一条人类重写路径。环境变量永远不进仓库。
# commands the pair typed; keys never committed
export MODEL_BASE_URL="http://127.0.0.1:8080/v1"
export MODEL_API_KEY="$MODEL_API_KEY"
export MODEL_NAME="$MODEL_NAME"
node pairing-eval.mjs proposed-retry.js
# expected: SHAPE GATE FAILED with import, env, and console.log lines
第一次运行在三块白板规则上全部失败。那次失败才是有用的输出。agent 草稿没能活过 pairing。后来的人类修改——用现有的 secret 参数重试、保留 fetch、只记录 res.status——通过了同一个脚本。
来自 session 的决策表
穿越死胡同活下来的决策很窄。pair 拥有 fixture。模型可以提议文本。评估主机是一次性的。生产 secret 永远不加入 prompt。
模型被允许待在哪里
fixture 存在之后,pair 仍然需要一个既不是笔记本也不是消费级聊天标签的地方。披露:本文是 MonkeyCode 产品推广的一部分。MonkeyCode 的免费模型访问和免费服务器选项仅作为一次性评估面有意义:一台 pair 可以镜像、在上面跑 node pairing-eval.mjs、然后删除而不会碰到 checkout clone 的主机。
他们没有把那台主机当作 benchmark 实验室。没有在工单里记录 token 数、硬件或模型名。工单记录了白板规则和通往 pairing-fixture.json 的路径。如果免费服务器明天消失,fixture 和形态门仍然会是 pairing 的产出。
合并进来的辅助函数比 agent 草稿更短。它保留了原始签名。它对 non-2xx 重试,上限三次。用 secret 参数做签名。日志只记录状态码。
export async function deliverWebhook(url, payload, secret) {
const body = JSON.stringify(payload);
let lastError;
for (let attempt = 1; attempt <= 3; attempt += 1) {
const res = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-signature': secret,
},
body,
});
if (res.ok) return res.json();
lastError = new Error(`webhook status ${res.status} attempt ${attempt}`);
await new Promise((r) => setTimeout(r, 200 * attempt));
}
throw lastError;
}
形态门通过了。原有的状态码测试仍然通过。高级工程师加了一个 agent 没有写过的额外测试:一个 spy,断言 console.log 从未收到 payload。那个测试是 pairing 的产物,不是模型的产物。
形态门是一个字符串过滤器。它会漏掉混淆后的 import、动态 env 查找,或包装在辅助函数里的 logger。它也会误杀一个在注释里提到了禁用标识符的有效补丁。pair 接受了两种失败模式,因为那道门在 review 前面,不是在 review 的位置上。
这个工作流假设 pair 能在提示之前命名不变量。不能在十分钟内写好 pairing-fixture.json 的团队只会产生一个更模糊的第二次 agent 循环。免费评估主机不能减少那个工作,只能把工作从笔记本上移开、不让它进聊天窗口。
关于模型排名、"AI 已经比大多数开发者更好"或公开排行榜分数的时效性声明被排除在工单之外。session 没有测量那些声明。它测量的是一份具体草稿是否违反了一块 pair 午饭后仍然能读的白板。
谁不应该用这个
当 diff 已经公开且不包含客户数据时,跳过评估主机路径。README 错字不需要一次性服务器。当变更是一个生成的解析器且没有稳定标识符可以禁止时,跳过形态门。当 on-call 事故需要一个一行 revert 而不是重试辅助函数时,跳过整个 pairing freeze。
已经运行带 secret 扫描的隔离 CI runner 的 Staff 工程师可能会觉得这套工具多余。价值出现在嘈杂的 pairing 桌上,不在成熟的平台组织里。
session 以一个无聊的合并结束。有趣的部分从未到达 main。它留在三个死胡同、五块白板问题,以及 pair 拒绝重开的那个决策里:fixture 保持人类所有,模型永远不从放着生产 env 文件的同一台笔记本上的聊天窗口里给补丁打分。