你说的「修好了」,是在哪台机器上?|Agentic AI 落地方法论(十四)

《Agentic AI 落地方法论》系列第十四篇。 前面几篇都在讲上线前怎么设计、怎么卡准入闸;这一篇讲上线之后那条最容易被忽略的鸿沟——代码修好了,生产没修好。English version: Your Code Is Fixed. Production Isn't..
老板盯着屏幕问「这问题上周不是修了吗」
上周合并的那个 bug fix,测试全绿,代码 review 过了、merge 了、部署了。这周复盘会,业务方翻出一条新会话:用户就发了两个字「谢谢」,agent 甩回一段兜底话术「您的问题我们已记录,稍后人工联系您」。老板盯着屏幕,一句话砸下来:「这问题上周不是修了吗?」
场面很安静。因为从工程视角看,它确实修了——那个改分类的 commit 三周前就进了 main,本地跑「谢谢」路由得又快又对,pytest 那一行绿得发亮。review 走完了、merge 也进了。可生产里,它像什么都没改过。有人小声说了句「是不是没部署上去」,一查部署记录,明明白白,三周前就 deploy 了。首字延迟 p95 飙到 18.5 秒,intent 那一段 span 独占 42.7% 的全链路时间。
顺着 Langfuse 一层层往下扒,真因不在代码里——部署那台机器的 .env,还指着一个网关早就下线的模型名。每一轮 intent 分类先打那个 dead model 拿 503,然后 fail-open 退回又慢又笨的老分类器。代码升级了,那台机器上的一行配置没跟着升。
这就是这篇要讲的那条鸿沟,一句话概括:代码修好了 ≠ 生产修好了。配置漂移 survives redeploy——它不随你 redeploy 一起被修掉。
.env、relevance 阈值、feature flag、reindex——这些东西 git push 一个都不携带。你以为一次部署就把它们一起带上去了,实际它们各自躺在某台机器、某个索引、某份没人重跑的脚本里。下面四个真实的坑,每一个都是「代码是对的、生产是错的」,收尾给你一张能带去复盘会的核对清单。
git push 携带代码和测试,却不携带 .env 模型名、relevance 阈值、feature flag、reindex——这是四个独立的交付步骤,漏一个就是「代码对、生产错」。
「谢谢」也走兜底,不是模型笨,是那台机器的 .env 配错了
先看清一件事:当「谢谢」「你好」这种最简单的输入都走了兜底,问题几乎一定不在模型,在部署机那行配置——router 全程在 fail-open。
复盘那条「谢谢」会话时,第一反应都是「意图分类没训好」。可全链路复现一遍,router 本身 12 / 12 全对——把同样的 12 条输入直接喂给分类逻辑,每一条都路由正确。那模型是清白的,慢和错从哪来?
答案在延迟分布里。p50 只有 2.1 秒,看着还行;但 p95 是 18.5 秒、max 到 25.7 秒。这种「中位数正常、长尾爆炸」的形状,就是有一条路径在反复重试。拿 diagnose_latency.py 把每条 trace 拆成 per-span,intent 这一段 p95 到 14.7 秒,占全程 42.7%;而且 intent=unknown 和「慢」强相关——慢的那些几乎都分类失败了。
拿模型名直接打内网模型网关,503。盯着那行响应看了几秒才反应过来,原因蠢得让人生气:router 默认写的是 qwen-turbo,网关上线的那个 tier 叫 qwen-turbo-latest——差一个后缀。每次请求先打 qwen-turbo 拿 503,代码里那句 except: fallback 于是不声不响退回老的 HybridClassifier,intent 延迟直接翻倍。整条链没有一个地方报错,日志里干干净净——因为 fail-open 的本职就是「悄悄降级、别惊动用户」。可这次它悄悄降级掉的,是三周前那次修复的全部意义。同一个网关实测三个档位:qwen-flash 0.42 秒回,qwen-turbo 直接 503,qwen-max 3.3 秒——能用的好好的,就是没人把默认那个名字对上。
更阴的是第二处。LLM_CRITIC_MODEL 这个环境变量根本没设,critic 于是回退到 chat 用的 max 档。max 档慢,单次 critic 8008 毫秒 ≈ 8 秒,直接撞 timeout。而 critic 是 fail-closed 设计——超时就升级人工。听起来很安全对吧?结果是每一笔退款都因为 critic 超时被误转人工。一个本该只在「不确定」时才触发的安全网,因为一行没配的模型名,变成了大规模误伤。fail-closed 的设计没错,错的是它在一个「稳定超时」的模型上跑——安全网被一根配错的绳子吊着。
这里有条金句,值得钉在墙上:.env 不随代码发布走——代码升级 ≠ 配置升级。 而且还有个更隐蔽的坑:开发时习惯用 uvicorn --reload,但 --reload 只监听 .py 文件,它不监听 .env。你在服务器上改了 .env,进程根本没重读,「我改了」不等于「它生效了」——得 pkill uvicorn 真重启才算数。这一条我自己都栽过:改完 .env 盯着日志等了十分钟,纳闷怎么没变化。
修复不是改代码逻辑,是给配置上锁。config.py 里把默认从 turbo 改成实测能用的 flash;.env 里 router 和 critic 的模型名都显式写死、不吃默认;.env.example 补 7 行启动清单,写清楚哪几个变量必须设。最关键的一条:启动 preflight 加一个模型可用性断言——服务起来的第一件事,拿配置里的每个模型名去 ping 一下网关,503 就直接 fail loud、拒绝启动,而不是 silent degrade 到老分类器还装作没事。宁可起不来,也别带病上线。
你的安全阈值可能是个摆设:过了 94 个测试,生产一次没拦过
一道 merged、94 个测试全绿的安全阈值,在生产里可能完全是死的——因为「测试通过」证明的是它存在,不是它活着。
这个坑比上一个更让人后背发凉。系统里有一道 off-topic 防线:用 cosine relevance floor 把「跟本店无关的问题」挡在知识库检索之外,避免 agent 拿着不相关的文档一本正经地胡答。代码在、逻辑清楚、94 个单测全绿。
生产里它一次都没拦过。这句话不是推测——是把上线以来的 off-topic 触发日志拉出来数,那道 floor 的拦截次数是 0。一道被写进设计文档、进了验收清单、94 个测试保着的防线,生产拦截次数是 0。
顺着往下查:ES 索引的 content_vector mapping 是有的,字段定义得好好的,第一眼看过去「向量都配了嘛」。但真去数每篇文档的向量维度,221 篇文档,0 条真的算过 embedding。mapping 骗过了第一眼。链条是这样断的——.env 里 embedding model 那行是空的 → 代码不建 embedding client → ES 的 kNN 分支起来就 except 静默吞掉 → fallback 到纯 BM25 关键词检索。而离线那个 index_to_es.py 脚本,从头到尾就没算过向量。mapping 是给向量准备的空房子,但没人搬进来住。
纯 BM25 拦不住 off-topic。有条测试输入是「你们有 Nike 1 号的衣服吗」——跟本店业务八竿子打不着,relevance 却给了 0.614,反而比大多数正常 query(大头落在 0.49–0.71)还高。BM25 匹配到了「衣服」「号」这些高频词,分数就虚高。只有 cosine 才分得开:真正相关的活动 query 是 0.595,无关的压在 0.39 以下——语义空间里它们本来离得很远。
修复分两步:先 dry-run 把 221 篇全 embed 一遍,确认 221 / 221 都有向量;再拿真实分布校准,把 kb_min_relevance 定到 0.52——正好卡在相关的 0.595 和无关的 0.39 之间。
对落地负责人来说,这一节的 takeaway 只有一句:一个 merged、green-tested 的安全机制,可能在生产完全是死的,因为索引里没向量、fallback 是个静默的 except。 下次工程说「off-topic 防线做好了、测试都过了」,别信「测试过了」,要问一句能证伪的:索引里现在有多少条真带向量?这道阈值上线以来在生产拦过几次? 拦过 0 次的守卫,跟没有是一回事。
content_vector 的 mapping 在,但 221 篇文档 0 条真带向量 → kNN 分支静默 except → 回退纯 BM25;off-topic「Nike 1 号衣服」rel 0.614 反而比正常 query 还高,只有 cosine 分得开(floor 校准到 0.52)。改了 KB 却永远不生效?先别急着 reindex——那是在修错的东西
当业务改了知识库、答案却死活不变,第一反应「是不是索引没更新、reindex 一下」——十有八九是范畴性错误的修法,因为那条答案的源头根本不在 KB。
真实场景:用户问「我这个能保价吗」,agent 每次都回一段固定的转人工话术。业务方后台把「保价」相关的知识条目改了好几版,线上纹丝不动。工程也急,准备 reindex——重建索引、重算、重灌,一套折腾下来,还是不变。
因为答案根本没走 KB。这条链路有三层,任何一层都能单独把它挡在 KB 之外:
- 第一层,路由 enum 里就没有「保价」这个类。 POLICY 的 sub_topic 枚举里没有 price 相关的意图,LLM 只能按名字相似度,把它硬塞进最像的那个
ACTION:price_diff_refund——从一开始就路由到了别处。 - 第二层,那条 workflow 里的话术是硬编码的。 命中的私域分支直接返回一个写死的常量
_PRIVATE_TRANSFER(转人工话术),这段代码从不读 KB。你改一万遍知识库,它也不会看一眼。 - 第三层,就算前两层都对了,也还有个白名单挡着。
price_inquiry虽然属于SHOP_DOMAIN,但不在SHOP_KB_ANSWERABLE白名单里 → 被导购 stub 拦下,走不到 KB 检索那一步。
三层各挡各的,reindex 一个都解决不了——它修的是「索引陈旧」,可这里没有陈旧的索引,只有一条压根不经过索引的代码路径。
这一节给你两个能立刻用的诊断动作,比 reindex 靠谱得多:
第一,grep 话术串到源头。 把那句固定话术里的一段独特文字,直接在代码库里 grep。命中 app/workflows/*.py 或某个 router 常量 = 硬编码,KB 永远不会被读到,改 KB 是白费。命中 KB 数据文件才说明答案真来自知识库。这一步 30 秒,能救掉一整轮 reindex 的折腾。
第二,判断索引到底陈不陈旧,用字节比对,别猜。 把 ES 里的文档正文和源文件逐字节比。真做了一次:6 篇 docs 全部 EXACT MATCH=True——一枪证明索引跟文件完全一致,索引是无辜的,问题在别处。顺带一个坑:别用 match_phrase 去中文里搜整句,中文分词会切碎你的 phrase,返回假阴性让你误判「索引里没有」。部署一条新答案的正确动作是重启 uvicorn 让配置生效,不是 reindex。
你以为没上线的东西,正在别人的机器上跑
「那个数据我们还没上线」——这句保证只在你自己那台机器上成立;共享一个生产索引的系统里,别人的 seed 数据可能正在应答你的真实用户。
又一条真实客诉:一个灰度里的 wxapp(私域小程序)用户问「你们包邮吗」,agent 答「满 99 元包邮」。用户凑单到 99 下了单,结果被客服告知私域实际门槛是满 399——差着 300 块。一句凭空的假承诺,直接变成投诉,而且是 agent 自己「查过知识库」之后自信给出的,比不回答更难解释。
「满 99」这个数字哪来的?是抖音公域的包邮门槛。两层错叠在一起:
- 私域检索从不按渠道 filter。 私域和公域的文档灌在同一个 ES 索引里——286 条 wxapp、249 条 douyin 混在一起。私域用户来问,检索却在全索引里搜,抖音的「满 99」自然被召回。
- 抖音那批 seed 本就不该进生产。 公域上线被推迟了,那批 douyin 数据本该留在测试。但
index_to_es.py --recreate默认会把整个 xlsx 都读进来,抖音 seed 就这么混上了生产。
底下还垫着一个更硬的问题:没有 test / prod 隔离。 .env 里 ES_URL 直接指着生产的阿里云集群,两边的 index 名硬编码成一样。于是任何人在自己笔记本上跑一次 index_to_es.py --recreate,删的、重建的,都是生产索引——重建那几秒钟,线上是空的,所有用户全走兜底。这批脏数据清了三次才净:249 → 286,第二次漏传了 --douyin-xlsx "" 参数,抖音 seed 又复活了一遍,第三次才彻底删干净。
verdict 很直接:一个不带渠道的 partition 字段 + 一个 test/prod 共享的索引 + 一台 .env 指着生产的笔记本 = 一次手滑 --recreate 就能删掉生产。 一个 bare general 兜底桶会让不同渠道的数据串味;「seed 没进生产」这种保证,在别人的机器上根本不成立——因为「别人的机器」和「生产」之间,可能只隔着一行相同的 ES_URL。
检测工具箱:一张发布清单 + 5 个复盘会该逼问的问题
把上面四个坑倒过来,就是一张发布清单——它的核心是承认:模型名 / 阈值 / feature flag / reindex 都是「非代码交付项」,git push 一个都不带。
发布一次改动,除了代码,逐项确认这些独立于代码的东西有没有跟着到位:
| 交付项 | 谁携带 | 上线前必须确认 |
|---|---|---|
| 代码逻辑 | git push | 测试绿、review 过 |
| 模型名(router / critic) | 部署机 .env | preflight ping 过、非 dead model |
| relevance / critic 阈值 | 配置 + 索引 | 索引里真有向量、阈值校准过分布 |
| feature flag / 白名单 enum | 配置 + 代码常量 | 目标意图真在可答白名单里 |
| KB / reindex | 离线脚本 + ES | 字节比对一致、话术源头在 KB 不在 workflow |
然后是 5 个能带去复盘会、当场逼问的问题:
-
「谢谢 / 你好」也走兜底 = router 全程 fail-open。 先查部署机那台的
.env模型名,别急着改代码——最简单的输入都错,问题在配置不在模型。 -
让工程证明守卫是「活的」,不是「存在的」。 别接受「测试过了」,问两个能证伪的数字:索引里现在有多少条真带向量?这道阈值上线以来在生产拦过几次?
-
改了没生效,reindex 前先做两件事。 grep 话术串到源头(命中
workflows/*.py= 硬编码、KB 白改);再字节比对 ES vs 源文件判断索引到底陈不陈旧。别拿 reindex 当万金油。 -
发布清单必须含非代码项。 模型名、阈值、feature flag、reindex——
git push一个都不携带。谁负责在部署后把它们逐项 check off,写进流程。 -
供应商三问。 改一条知识库答案、调一个阈值、开一个 feature flag,各自要经过什么步骤、多久、由谁负责,才在生产真的生效?答不上来,就说明他们也不知道自己的配置漂移在哪。
这五条的共性是同一个动作:不看「代码对不对」,看「生产里那台机器 / 那个索引 / 那份配置,到底是什么状态」。 代码在你的仓库里,生产的真相在别处。
领取这篇的核对清单
如果你想把这套东西直接用到下次发布和复盘——不用每次都翻这篇——我整理了一份工具包给读到这里的人。回复关键词「生产核对」,我把《生产核对清单 + 5 个复盘会该逼问的问题》发给你:一页版发布清单(模型名 / 阈值 / flag / reindex 逐项 check off)、5 个逼问问题的卡片版、以及「代码对了但生产错了」的四类症状速查表。都是这一年跟客服 Agent 项目踩出来的判断工具。
回复渠道见页脚(公众号 / X)。不方便回复的,评论区留邮箱也行。
Subscribe for updates
Get the latest AI engineering posts delivered to your inbox.



