1284 字
6 分钟
契约里的「做不到」,在代码里只能有一个出处

一个服务把七种外部 agent CLI 包成一套对外 API,承诺所有运行时收同一套字段。实际上有些字段在某些运行时没有原生机制可接。这些缺口先是写进设计文档的一张表,代码里再各写一份判断。逐格核对的时候发现表里有一条「常驻模式下 instructions 原生生效」,从来没成立过。我的判断是:契约里每一条「这里做不到什么」,在代码里只能有一个出处,由它同时决定行为和回给调用方的那句话。

能力矩阵的三档处理,以及同一条判断的副本出现在哪几处

缺口要分三档,不能只有支持和不支持#

一个字段配一个运行时,这一格有三种写法。

真适配——接上运行时自己的机制。系统指令在其中一个运行时走 --append-system-prompt。思考预算在它那里是 MAX_THINKING_TOKENS,关是 0,高是 16384;在另一个运行时是 model_reasoning_effort,调用方填的档位超出它的枚举就往下钳到 high。

降级,并且明说——另外两个运行时读系统指令的原生通道是工作目录下的 AGENTS.md。服务端不代写这个文件:它会覆盖调用方自己的那份,而且会被自动提交打进包。这一格的处理是不做,回一条 notice 告诉调用方自己写。

改路——请求里带了常驻路径读不到的字段,这一轮自动换到一次性路径上跑,让字段真的生效。

只有支持和不支持两档,会把后面两种压进同一格。压完之后「不支持」这个词既可能是真的不做,也可能是换了条路跑完,调用方照着它写不出代码。

路由和回给调用方的话,读同一个常量#

「常驻路径读不到哪些字段」写成一个常量 RESIDENT_UNSUPPORTED_FIELDS。路由靠它决定这次要不要改走一次性路径,notice 文案也靠它生成。

分开写也能跑:路由一份判断,文案一份判断。时间一长,两边会说不同的话。加一个新字段,改路那边记得加了,文案那边忘了,于是请求被换到另一条路径上执行,调用方收到的是一条没提这件事的成功响应。

这种漂移不报错。两份判断各自都对,只是不一致。

代价是每次加字段都要回到这一处,写错了,路由和 notice 一起错。换来的是行为和文案不会分叉。

合法集手写,就是第二份副本#

同一个服务里还有一处同样的事:查询参数校验。

Web 框架默认丢弃路由没声明的 query 参数。把 owner 拼成 owners 不报错,筛选被无声丢掉。改成返回 422,列出未知项和合法集。

要紧的是合法集从哪来。手写一份清单,它就成了路由声明的第二份副本,新增端点要靠人记得同步。实际做法是让校验去读框架自己的那份声明——递归收集 route.dependant,含子依赖——挂在 router 级依赖上,写一处,全部端点生效。

代价是绑死在框架的内部结构上。route.dependant 不属于它的公开契约,大版本动了它这道校验就坏。跨版本升级我没有测过。

跨服务的那份副本,要等下游报回实际值#

前面几处的出处都在同一个进程里,常量管得住。跨服务就管不住了。

上游传进来的模型名是自由字符串,带厂商前缀的、带日期后缀的、空串都有,而下游每个运行时只认自己的枚举。第一版两边各存一份映射:先精确命中白名单,再宽松匹配常见写法,认不出就回退到本运行时的默认值并打 warning。

这套映射自己也被改过两次。回退值从较强的那个模型换成最快的那个——兜底跑哪个模型是产品决策,默认值不能照着「性能最好」填。另一处是调用方填的模型名不带版本号,这时返回具体版本,不走回退常量,否则调用方明确选了一档,实际落到另一档。

改完还是会漂。两个服务各一份白名单,谁先加了新模型谁就是对的,另一边不知道自己已经过期。

最后整套退役:上游原样透传,归一化只在下游做一次;下游在流开始时回传这一轮实际在跑的模型,前端标签照着它显示。副本就没了——前端不再自己推断,显示的是下游报上来的那个值。

前提是下游肯回传,而且回传得够早。流开始之前那一段,界面上只能显示请求值,这一段有多长我没量过。