过去一个月,我在一个 agent 配置仓库里改动了 51 个文件、约 2500 行。改的不是业务代码,而是 AI agent 自己的"运行环境":模型路由、权限矩阵、提示词缓存、上下文压缩、技能治理。这些问题很少出现在业务开发教程里,但每一个都真实地烧过 token、坑过行为。这里挑九件事记录一下,多数与具体工具无关。
一、最危险的配置 bug 是不报错的那种
一个月内先后揪出三个同族 bug,全是键名单复数写错:
permission.skills—— 实际生效的键是skill(单数)。技能隔离块(deny-all 加 allowlist)从引入那天起就是死的;attachments.image—— 实际是attachment(单数)。图片自动缩放配置死了三天才被 schema 对照发现;- 一个手写的
compat兼容块 —— 字段路径根本不被解析器读取,整块是死代码。
共同点:配置解析器对不认识的键静默忽略。没有报错,没有警告,配置"看起来生效了",行为上只表现为"模型不太听话"——而这种症状太容易被归因到提示词写得不好。
应对方式:
- 用工具自带的 JSON Schema 做严格对照,或直接读配置解析源码确认键名;
- 把配置校验放进 CI:解析配置文件,递归比对 schema,未识别的键直接报错;
- 行为验证:改完配置要真实触发一次确认生效,"改完了"不等于"生效了"。
教训:对声明式系统,"没报错"和"生效了"之间隔着一条鸿沟。
二、一段正则的两条命:从丢转义到状态机
JSONC 解析前要剥离尾逗号。第一版正则写成了 /,(s*[}]])/g——s 丢了反斜杠,变成匹配字面字符 s。
这个 bug 阴险的地方在于它是"半死"的:s* 可以匹配零次,所以 ,} 这种紧凑写法能匹配成功,而 , } 这种带空白的——恰恰是尾逗号最常见的排版——永远匹配不上。偶发的成功掩盖了失败路径,比全死的 bug 难发现得多。
第二版修正为 /,s*(}|])/g。但正则方案有先天缺陷:它不认识字符串字面量。提示词模板里一旦出现 "list: a, }" 这样的内容,字符串内部的 , } 会被误删。
第三版放弃正则,重写为逐字符状态机 stripTrailingCommas():跟踪 inString 状态和反斜杠转义,只在字符串外剥离逗号。三十行,零依赖,语义正确。两处 JSONC 剥离器统一换用。
教训:正则处理不了有嵌套上下文的内容(引号、转义)。当你发现自己在为正则不断打边界补丁时,就是换状态机思维的时候。
三、权限设计:默认 ask、last-match-wins 与提权级联
bash 权限原本是 "*": "allow"——全放行。问题是破坏性命令的变体列举不完:rm -rf 换个拼写、加个空格、套一层别名,都会命中 * 直接执行。
改成默认 "*": "ask",外加高频安全命令的显式 allowlist(git status/diff/log、node scripts/*、npm run/test、rg 等)。关键机制:权限列表按 last-match-wins 解析,通配符必须放在列表最后——放前面它会先命中,后面所有精细规则全部变死。顺带把 git push --force-with-lease、git clean -fd、git checkout . 这些"半破坏"命令也归入 ask。
另一个容易忽视的是提权级联:写者 agent 全部加了 task: "*": "deny"。如果主 agent 的写权限受限,却允许它派生子任务,那它可以委派一个不受限的子 agent 干活——限制形同虚设。权限矩阵必须在委派维度上闭合。
权限还能表达"分工"而不只是"防御":/simplify 流程设计成两段式——只读的贵模型做分析,便宜模型执行机械编辑。执行者的权限矩阵里只给分析者开 allow,其余委派全 deny。流程结构直接编码进权限矩阵,不依赖提示词自觉。
四、思考强度是请求参数,不是模型
reasoning_effort(配置里叫 reasoningEffort,请求体里是 snake_case)是请求级的思考强度控制(low/high/max),不是独立的模型 id。有的框架会自动生成 -high/-low 模型变体,有的不会——不会的就只能靠 per-agent 的 options 深度合并注入,绝不能动模型层配置,否则模型矩阵会爆炸。
由此得到三层路由:
| 层级 | 典型角色 | 配置 |
|---|---|---|
| trivial | 搜索、查资料、纯问答 | 便宜模型 + thinking disabled(provider 级关闭,官方省钱开关) |
| mid | 规划、常规多文件实现 | 便宜模型 + thinking enabled + reasoningEffort: low |
| deep | 根因分析、评审、重实现 | 贵模型 + 默认 high |
原则:模型选择解决"能不能做",思考强度解决"想多认真",两个维度正交。别用贵模型解决强度问题,也别指望便宜模型靠拉高强度追上深度推理。
附带一个冷知识:thinking 开启时 temperature/top_p 被静默忽略。所以便宜模型(thinking off)设温度 0 有意义,贵模型(thinking on)设了也是白设。
五、token 经济学:六个计数细节
给两个模型建了成本表(USD/1M tokens):便宜档 input 0.22 / output 0.66 / cache read 0.007;贵档 input 0.66 / output 1.98 / cache read 0.022。围绕这张表有一串反直觉的发现:
1. cache write 没有独立价格。 提供商不单独公布缓存写入价——缓存写按 cache-miss 的输入价计费,所以成本表里 cache_write 直接映射自 input。建模时若拿"平均价"糊弄,会系统性低估首轮成本。
2. cache read 比 input 便宜 30 倍。 这直接决定了提示词纪律:字节稳定前缀。所有易变内容(时间戳、随机 ID、动态文件列表)必须追加到 payload 尾部——前缀里一个字符的变动就会击穿整段缓存,重新支付全价输入。规则文件、agent 提示词的顺序都不能随意重排,哪怕语义等价。
3. 贵模型早压缩。 上下文压缩器(DCP 插件)的阈值改成按模型区分:贵模型 55K/26K,全局默认 77K/38K。贵模型输入价是便宜模型的 3 倍,同样 token 数烧的钱不同,压缩阈值理应不同。压缩阈值是经济参数,不是技术参数。
4. 百分比阈值在大窗口下失效。 模型窗口 1M 时,"到 60% 再压缩"意味着 600K——普通会话(20K–200K)永远触发不了。改成绝对 token 阈值才符合实际会话分布。
5. 负成本。 成本估算脚本一度算出负数:cache-read 的 token 计数可能超过 raw input 计数(两边口径不一致)。修复是一行 Math.max(0, input - cacheRead)。所有派生指标都需要物理约束兜底——成本不可能为负,上下文不可能为负。
6. 输出裁剪有最优区间。 工具输出裁剪设 800 行 / 20KB,比官方默认 2000 行保守。但试过更激进的 200 行 / 8KB 后回退了:正常文件读取被截断,agent 只好反复重读,端到端 token 反而更贵。省 token 的目标是总成本,不是单条消息的体积。
六、同步脚本的删除对账:用 git 历史定义"受管集合"
配置仓要同步到全局目录(独立副本,不是 symlink)。复制好写,难的是删除:仓库里删掉的技能会残留在全局目录里继续生效——幽灵配置。
又不能简单做"镜像删除",因为全局目录里可能有用户自建、从未进过 git 的文件,误删不可恢复。解法是用 git 历史计算受管集合:
$managed = (git ls-files) `
+ (git log --all --diff-filter=D --name-only)
即"当前跟踪的文件 ∪ 历史上任何时刻删除过的文件",剥掉仓库前缀得到相对路径。同步时只对账 skills/agents/commands 三个受管子目录,删除条件严格限定为:在 $managed 中、且源里已不存在。用户本地自建文件因为从未被 git 跟踪,永远不会进受管集合,天然安全。配合 -WhatIf 预览和 -Destination 测试覆盖。
还有一个 Windows 特色坑:8.3 短路径。调用方可能传 C:UsersADMINI~1...,而 Get-ChildItem 返回解析后的长路径,直接拿字符串做 Substring 算相对路径会错位。先 (Get-Item -LiteralPath $dst).FullName 归一化再计算。
教训:任何"删除远端多余文件"的同步逻辑,先回答"多余"的定义权归谁。让版本历史来定义,比让脚本现场猜测可靠得多。
七、绕开视觉模型的硬约束:一张图不够,就切九张
多模态通道有两个硬约束:拒收 PDF(只认 JPEG/PNG/GIF/WebP);每张图内部降采样到约 800×800 的像素预算。后者的后果是——大截图、文档照片里的小字,降采样后不可读,无论你上传多高的分辨率。
对策全部放在客户端预处理,把问题搬进自己能控制的域:
- PDF → 栅格化:按页子集转换,zoom 2.0(约 144dpi)平衡清晰度与体积;
- 大图 → 切 N×N 瓦片,带 10% 重叠——重叠区防止文字正好被切断在瓦片边界;
- 每张瓦片单独发送,各享自己的 800×800 预算,等效分辨率翻倍甚至更多,结果在文本层合并;
- 经验规则:长边超过 1600px 或存在密集小字就切;300-DPI 密档直接 3×3。
上传端配套约束:auto_resize、长边上限 1600px、base64 上限 2MB。既然模型端必然降采样,超大上传只是浪费编码字节,还会撑大缓存前缀。
教训:面对模型硬约束,重试和祈祷没有意义;把约束读清楚,然后在上游把输入变换到约束的舒适区里。
八、agent 行为契约:结构性不可能、显式豁免与拒绝权
一个"单模型内联执行器"agent 的设计沉淀了三条提示词工程经验:
承诺要结构化,不要靠自觉。 "零委派"不是只在提示词里写"你不许委派",而是权限矩阵直接 task: "*": "deny",连后台助手工具一起 deny(它们跑在内置小模型上,会破坏单模型保证)。委派在结构上不可能发生。文字约束在长上下文里会衰减,权限约束不会。
局部与全局规则冲突要显式豁免。 全局规则写着"2+ 步骤先规划""能委派就委派",而这个 agent 的存在意义恰恰是全程内联。不处理这个冲突,agent 会真的去尝试遵守全局规则——调用一个不存在的子任务工具,然后失败。解法是在提示词里写一条"Intentional scope exemption":明确列出哪些全局规则在此被豁免、为什么。规则不会自动让位,冲突必须人工裁决并写明。
拒绝契约。 明确规定什么时候返回"干净的拒绝"而不是"降级的部分尝试":收到多模态输入(纯文本模型应告知换视觉通道,绝不猜测图片内容)、无法诚实完成任务时直说。半吊子交付比拒绝更贵——它占用验证时间,还掩盖了失败信号。
九、可达性治理:存在 ≠ 可用
技能库切到 default-deny 的 allowlist 模式后,出现了一种新型死代码:孤儿技能——文件存在,但没有任何 agent 的 allowlist 引用它,运行时永不可达。一次清点出 7 个孤儿,逐一接线到合适的 agent。
讽刺的是:接线时用的权限键本身就拼错了(见第一节),所以这次"接线"也是死的,几天后才真正修好。两层 bug 会互相抵消出一种假象——修可达性问题之前,先验证可达性机制本身是活的。
治理动作还包括收敛:技能总数 24 → 20;语义重叠的合并(词汇表类并入领域建模类,明确唯一 owner);两个评审命令合并为一个,内嵌范围门(有效 diff 超过 500 行时先输出分级计划而不是硬审);互斥场景的技能之间加交叉引用,防止 agent 选错工具。
所有"可插件化"的系统都会这样腐烂:添加没有成本,删除有阻力。需要定期做可达性审计——枚举所有资源,检查每一个是否存在活的引用路径。
结语
这一个月改动的主线可以压缩成一句话:声明式系统(配置、提示词、权限矩阵)的正确性不能靠"看起来对",只能靠可验证的机制——schema 校验、行为验证、结构性约束、可达性审计、git 历史对账。
另一条主线是:经济参数必须进入设计决策。缓存价格决定提示词排版,模型价差决定压缩阈值,裁剪尺寸决定端到端成本。一个系统可以技术上全对、账单上全错——而在 agent 基础设施里,账单就是行为的一部分。