IT加油站

用 AI Agent 替换 200 行代码后:这些没人写下的逻辑全崩了

27浏览 2天前 软件教程 MA123156

原文:https://dev.to/infoinlet1/i-replaced-200-lines-of-code-with-one-ai-agent-heres-what-broke-4dif(作者 @infoinlet1)

这是我写过的最无聊的 200 行代码,压缩后长这样:

// intent-router.ts
const ROUTES = [
  { when: /\b(invoice|receipt|bill)\b/i,          handler: 'billing' },
  { when: /\b(refund|charge ?back|dispute)\b/i,   handler: 'refunds' },
  { when: /\b(password|2fa|locked out)\b/i,       handler: 'auth'    },
  // …还有十四条,每条都有测试,每条都是在用户踩坑当天加上的
]

export function route(text: string): Handler {
  for (const r of ROUTES) if (r.when.test(text)) return r.handler
  return 'fallback'
}


这东西花了八个月才积累成现在这样。里面每一条正则都是一道伤疤。它在我们的 1200 行评测集上准确率 91%,而且没人愿意碰它。

这是替换后的代码:

export async function route(text: string): Promise<Handler> {
  const { handler } = await agent.classify(text, HANDLERS)
  return handler
}


六行。同样的评测集,同一个下午测完:96%。比八个月的正则高了五个百分点,而我为此删掉了 194 行代码。

我想把话说清楚,因为这篇文章后面的内容听起来会像个警告,但它不是:agent 干这活确实更好。 它从没变差过。那个数字稳住了。测量是真实的。

但它还是让我付出了三周的代价。

崩掉的不是准确率。崩掉的是那 200 行代码一直在做的、没人写下来的那些事——因为从来没人需要写下来。


实际发生了什么

三周后,客服转来一个工单。一个客户问了重复扣款的问题,结果被路由到了 billing 而不是 refunds。错误的队列,四天延迟,愤怒的客户。

我去查错误。

没有错误。

请求成功了。状态 200。agent 返回了 billing,这是一个真实存在的 handler,拼写正确,在枚举里,符合 schema。流水线里的每个检查都通过了,因为流水线里的每个检查都在验证答案是否格式正确——它确实是。答案格式正确但内容错误,这两件事以前从不需要区分——因为正则匹配失败时会落到 fallback,而 fallback 是显眼的。

于是我去复现。同样的文本,直接送进路由器。

返回了 refunds。正确。

我又跑了一次。refunds。再来。refunds。十次里有九次是对的。第十次不对,但我没法故意让第十次出现。

那才是真正的崩塌时刻。不是错误答案——是不可复现的错误答案。我整个职业生涯都建立在一个我从未明确说出的基础之上:用同样的输入再跑一次,我会得到同样的结果。 200 行丑陋的正则免费给了我这个保证。六行优雅的 agent 调用把它拿走了,还顺手拿走了大约五样其他东西——那些东西我是后来一个接一个绊倒才发现的。

下面就是它们,大致按咬到我的顺序排列。


1. 失败不再自我宣告

route() 还是正则的时候,一次未命中长这样:

route("my card got double charged") → 'fallback'


fallback 是一个真实的状态。它被记录、被计数,仪表盘上有它的磁贴,当它飙升时就会有人加一条正则。系统的无知是一种价值。它在类型里。你不可能不处理它。

route() 变成 agent 的时候,一次未命中长这样:

route("my card got double charged") → 'billing'


自信。合法。错误。没有 fallback,因为 agent 总会选一个——这就是选择的含义。我无意中移除了系统唯一能说我不知道的通道,而且我是在让系统变得更准确的同一个 commit 里移除它的,所以 review 时没人发现。

修复方法并不巧妙,但你得先决定要它:

const { handler, confidence } = await agent.classify(text, HANDLERS)
if (confidence < 0.8) return 'fallback'   // 把无知放回去
return handler


正则不会不确定,所以它通过失败来表达不确定——这是结构性的。agent 可以不确定,但它通过不提来表达。你不问,就得不到。

2. 缓存开始永久返回错误答案

正是这个问题,把糟糕的一天变成长达三周的噩梦。

路由器前面有一层缓存。当然会有——它已经存在一年了,以输入文本的哈希值作为键,并且在这一年里的每一天它都是正确的,因为纯函数的输出可以由其输入进行缓存。这就是“纯”的含义。

const key = sha256(text)
const hit = await cache.get(key)
if (hit) return hit
const handler = await route(text)
await cache.set(key, handler, { ttl: '30d' })


现在再读一遍这段代码,当它背后是一个非确定性的 route() 时,它就不再是一个缓存了。它变成了一个带有 30 天记忆的抛硬币游戏。 某种特定表述第一次通过时,AI 代理碰巧给出的回答会成为该表述在未来一个月内的永久答案。如果那次恰好命中了那 2% 的错误率,那么在接下来的一个月里,每一个以这种方式提问的用户都会被路由到错误的队列。而且是稳定地路由到错误的地方。这使得它看起来像是一条路由规则,而不是偶发的随机故障,这正是为什么花了三周时间才发现它的原因——我一直在寻找规则中的 bug,但那里根本没有规则。

缓存本身没有任何改变。没人动过它。它也没有崩溃。只是它所依赖的基础前提不再成立了,而它根本无从知晓。

去检查你的缓存、memoization、useMemo、幂等键,以及基于哈希的去重逻辑吧。它们中的每一个都是一份写着“相同输入,相同输出”的契约。当你把 AI 代理置于这些机制之下时,你并不是在添加功能。你正在悄无声息地、从一个你从未打开过的文件中,使得代码库中其他已经依赖该契约的部分失效。

3. 成本变成了流量的函数

这 200 行代码在每天 10 次请求和 1000 万次请求时的成本是一样的:都是零。CPU 占用几乎可以忽略不计。这并不是一个微不足道的特性,正是这个特性让你根本不需要去考虑它——你可以在循环里调用 route(),为了省事可以直接调用两次而不用费心传递结果,甚至可以在每次按键时都调用它。

而我们确实这么做了。有一个地方——一个实时预览面板——在输入发生变化时就会调用 route(),防抖时间设置为 150ms,因为它是免费的。

现在它不再免费了。调用处的代码没有任何改变,没人编辑过那个文件,但它的成本却从零变成了一张账单。

更糟的是:成本不仅与每次调用有关,还与 token 数量有关。一个在输入框里粘贴长邮件的用户,其成本实质上远高于一个只输入 "refund" 的用户。你的单位经济效益中现在多了一个由用户控制、且团队中没有任何人跟踪的变量,因为当代码还是正则表达式时,根本没有什么需要跟踪的。

4. p99 指标不复存在

旧的 route():大约 0.1ms,有趣的是这个数字有上限。用 17 个正则表达式去匹配一个长度有限的字符串。没有任何输入能让它耗时一秒。

新的 route():p50 大约 600ms,p99 大约 4s,而且——关键在于——根本没有上限。 不是缓慢上升的上限,而是完全没有。上游服务可能会挂起,可能会对你进行速率限制,也可能会在某个区域返回 503 错误。你的 p99 现在受制于别人的基础设施,你会在他们的故障期间而不是你自己的故障中发现这一点。

所以,关于超时的问题,根本没有完美的答案:

  • 超时设置为 2s,你就会把 3% 的慢响应尾部变成 3% 的失败率。
  • 超时设置为 30s,意味着你要为了一个路由决策将连接保持开启长达半分钟。
  • 不设置超时,上游一旦出点问题就会耗尽你的连接池,并拖垮那些与路由毫无关系的端点。

正则表达式从未让我做过这种选择。同步、有界、搞定。我以前并未意识到 route()同步性是承载整个系统运转的关键,直到把它改成 async 后,影响波及到了四个调用处,而其中一处正位于高频循环中。

5. 测试不再具有测试意义

旧测试:

expect(route('reset my password')).toBe('auth')


确定性、瞬时、免费,并且当有人破坏了路由时它会 失败。这就是测试的全部职责。

你无法针对 AI 代理写这样的测试。好吧——你可以写,而且它大多数时候都会通过,但它会在某个周二莫名其妙地亮红灯,不出两个 Sprint,就会有人把它标记为不稳定测试并跳过。这不是假设,这就是一个因无人能干预的原因而有 2% 概率失败的测试的最终宿命。

所以测试发生了变异。它们变成了:

expect(HANDLERS).toContain(await route('reset my password'))


这段代码断言代理返回了 某个处理器。它并没有断言返回的是 正确的 处理器。只要路由返回的是一个合法的枚举值,即使路由功能完全崩坏,测试也能通过。测试套件依然是绿色的。但这个套件现在只是个摆设。

真正能替代它的根本不是单元测试——而是一个 eval set(评估集),它按计划运行并报告一个 比率,当比率发生波动时还会触发警报。这是一个真正的解决方案,并且行之有效。但请注意它的代价:它是按计划运行的,而不是在 CI 中运行;它报告的是分布情况,而不是通过/失败;它无法阻止合并,因为你不能在一个不断波动的数字上设置合并拦截。你用仪表盘换掉了门禁,而仪表盘这种东西,是需要人们记得去看的。

6. 代码变了,但没有提交记录

替换六周后,每日夜间评估的准确率下降了大约一个半百分点,然后就停在那里了。我们的代码仓库什么都没改——我检查了,检查了两遍,还检查了 lockfile。

端点背后的模型被更新了。

好好想想这件事。我的生产代码行为发生了变化,但没有提交记录、没有 diff、没有代码审查,在 `git log` 里也找不到任何一行可以指给它。 我对"什么变了?"的所有直觉都建立在一个假设上:答案在版本控制里。对于那六行代码来说,答案不在版本控制里。它在别人的发布说明里——如果他们写了的话。

这是最深的一条,也是和"写更好的 prompt"完全无关的一条。在厂商允许的地方锁定版本——然后接受一个事实:你现在有了一个会过期的依赖。


我会写在墙上的那句话

删除代码的同时删除了它的保证。而保证不会出现在 diff 里。

这个 PR 显示 −194/+6,准确率提升了五个百分点。就 PR 中可见的证据而言,它显然是正确的,今天我还会批准它。

diff 无法展示的是,那 194 行代码一直在默默地提供确定性、有界延迟、零边际成本、回退状态、可测试性和变更历史——这些没有人要求过,但系统的其他部分已经悄悄地建立在它们之上。

那些正则表达式不是功能本身。它们是你能判断功能是否出错的原因。


我最终实际上线的东西

不是回滚。Agent 留下了,因为它确实更好。改变的是它不再孤军奋战:

export async function route(text: string): Promise<Handler> {
  // 1. 确定性规则优先——命中时成本低、即时、无歧义。
  const certain = RULES.find(r => r.when.test(text))
  if (certain) return certain.handler

  // 2. 其余的交给 agent,但它可以说「不」。
  const { handler, confidence } = await agent.classify(text, HANDLERS)
  if (confidence < CONFIDENCE_FLOOR) return 'fallback'

  return handler
}


大约 30 行规则保留了下来,不是 200 行——只保留那些真正无歧义的、命中就意味着什么的规则。它们以零成本、零延迟处理了大约 60% 的线上流量,而且更有价值的是,它们是系统中唯一能和 agent 意见不一致的部分。当每日夜间评估显示规则和 agent 在某个两者都有意见的用例上出现分歧时,那就是一个信号,而且这个信号在客户发现问题之前就到了。

缓存的键基于输入模型版本,TTL 从 30 天改成了 24 小时。它现在是一个成本优化手段,而不是对正确性的假设。

fallback 也回到了类型定义里,在我自作聪明之前的八个月里它一直都在那里。


我现在会问的四个问题

在删除确定性代码、改用 agent 之前,按这个顺序:

  1. 谁把它当作纯函数来读? 搜索下游的缓存、记忆化、去重和幂等键。每一个都是你即将从一个你根本没编辑的文件中打破的承诺。
  2. 它怎么表达「我不知道」? 如果答案是"它不能",那你删除的不只是一条代码路径,而是一个状态。在合并之前把它放回去,而不是等客户发现之后。
  3. 上限在哪? 延迟和成本的上限。如果没有,那你现在需要一个超时和一个预算,而这两个都是没有标准答案的产品决策。
  4. 回归时什么会变红? 如果什么都不会——如果唯一的监控只是一个人在仪表盘上读的数字——那你拥有的不是测试,而是期望。

以上这些都不是反对使用 agent 的理由。我会做同样的替换;五个百分点就是五个百分点,我绝不回去手动维护十七个正则表达式。

这是反对我真正做错的那件事——读到一个 −194/+6 的 diff 就以为自己看到了全部的变化。


如果你也经历过用 agent 替换确定性代码,然后看着下游的某个假设崩塌——尤其是缓存——我真心想听听是哪一个。我赌是缓存,但在这件事上我之前也判断错过。

原文:https://dev.to/infoinlet1/i-replaced-200-lines-of-code-with-one-ai-agent-heres-what-broke-4dif(作者 @infoinlet1)

#AI Agent #正则表达式 #意图路由 #LLM #代码重构 #技术债
1
1