引言:两个容易被低估的工程问题
在多 Agent 编排框架里,默认范式已经相当成熟:一个顶层路由按意图分配任务,子代理各司其职,模型按难度分级(便宜模型扛高频,贵模型啃硬骨头)。这套架构解决了成本和并行问题,但它默认了一件事:执行链路允许横跨多个模型。
于是有两个问题经常被低估:
- 当任务要求"全程只有一个模型"时怎么办? 可复现性验证、对单个模型能力的完整评估、对子代理隐式上下文的怀疑------这些场景下,跨模型链路本身就是不被接受的。这时候不能靠"尽量别委派",需要的是结构性保证。
- 平台大版本升级时,配置语义怎么迁移? Agent 配置框架(如 OpenCode v2)的特点是:键名写错、写旧、写错顺序,全都不报错------静默失效才是默认失败模式。迁移的难点不在改键名,而在建立一套"能发现没生效"的验证方法。
本文围绕这两个问题,拆解 solo(单模型内联执行器)的设计,以及 OpenCode v2 的配置语义与迁移要点。
一、solo:单模型内联执行器
1.1 它是什么,为什么要存在
solo 是一个 primary 模式的 Agent:分析与规划、实现与验证全部在当前会话所选的那个模型 上内联完成,不产生任何子会话、不切换任何模型。它存在的理由不是"性能更好",而是保证:整条链路只有一个模型在思考,行为可复现、上下文不跨模型、成本和延迟都收敛到一个档位。
1.2 "零委派"必须做在权限层,而不是提示词里
写一句"请不要委派给子代理"是最容易想到的做法,也是最不可靠的:它只是一个请求,模型可以判断"这次情况特殊"从而绕过。正确做法是用平台的权限系统把委派变成不可能的:
yaml
# agent frontmatter 示意
mode: primary
permission:
task:
"*": "deny" # 委派工具在权限层被彻底否决
关键区别:task: deny 之后,子代理工具对模型来说"不存在",而不是"不该用"。结构性保证永远优于行为性请求------这正是选择 solo 的意义所在("这个任务必须在单模型上完成")。
一个由此产生的细节:全局规则里通常有"多步任务先规划、禁止单干"之类的条款,这些规则在所有有委派能力的 Agent 上都是对的,唯独对 solo 是矛盾的------它的存在前提就是"委派已被结构性禁止,所以规划、实现、验证全部内联"。因此角色提示词里要把豁免写死,例如:
全局规则要求 2+ 步骤先走 planner、要求"委派而非亲力亲为"。这些规则在这里不适用:
task权限已被拒绝,委派在结构上不可能,所以规划(先写 TODO)、实现、验证都在本会话内联完成。这正是选择本 Agent 的意义。
不要指望模型在冲突的规则之间临场权衡------把豁免显式写进角色定义。
1.3 还得禁掉"内置助手"------因为模型解析链比想象中复杂
权限层只堵住了显式委派。很多框架还提供内置助手 Agent(如 build、plan),调用它们同样会产生子会话或一次性调用------而这些调用按各自的配置档位运行,通常落在另一个模型上(比如轻量 flash 档)。一旦调用,另一档模型就进入了链路,"全程单模型"不再成立。
所以在 solo 的提示词里应直接禁止调用这些助手,并说清原因:
不要调用 build/plan 等内置助手:它们运行在引擎内置的轻量模型上,会把另一档模型带进链路,破坏单模型保证。
这里有个容易写错的认知点,值得单独强调(§2.5 会展开):Agent 配置里的 model 字段只约束子会话、一次性调用和界面默认值,并不约束主会话。所以"solo 用哪个模型"其实由会话自身决定,与内置助手的档位是两套逻辑------文档和提示词里如果写混,就会出现自相矛盾的说明。
1.4 跟随会话模型:不要硬编码 model
solo 的配置里不写 model 字段,这是刻意的:执行器的前提是"用会话当前选定的模型跑完整条任务",硬编码等于偷偷换模型,前提就不成立了。
这带来两个连锁设计:
- 思考档随模型而定。 例如双模型场景下:默认 pro(thinking 开启、reasoning effort 高);会话切到 flash 时 thinking 关闭、temperature 固定为 0(由 provider 层配置保证)。文档里必须写成条件式,写成"固定在 pro/高思考档"就是错的------这个坑在实践里非常常见,因为早期文档很容易把"默认"当成"固定"。
- 多模态能力也随模型而定。 拒绝契约不能一概而论:如果默认模型是纯文本的,遇到图片必须拒绝并转交视觉 Agent(或提示切换到原生多模态的模型);但如果会话本身跑在多模态模型上,读图就是合法操作。拒绝条款要加限定语------"在当前会话模型为纯文本时"。
1.5 提示词的三块:铁律、拒绝契约、技能白名单
铁律(结构层的事实陈述):零委派、禁用内置助手、直接使用工具(bash/read/write/edit/grep...)动手。每一条都是对平台事实的描述,不依赖模型自觉。
拒绝契约(什么不做,比做什么更重要) :明确列出必须当场拒绝、不允许降级交付的情形:
- 多模态输入但当前模型不支持------直接告知用户正确的路径(视觉 Agent / 切换模型),绝不猜测图片内容;
- 任何无法诚实完成的工作------直接说明原因,绝不把降级或残缺的结果当作完成品交付。
拒绝优于降级,这是信任问题:一个"看起来很完整"的部分结果,比一次明确的拒绝昂贵得多。
技能白名单(deny 默认 + 全量放行) :技能名册(每个技能的 name + description)是每轮常驻上下文 ,所以常规做法是按职责最小化白名单。但 solo 是个例外------它要内联完成规划、实现、审查、Git、发布等所有阶段,因此放行完整本地技能链 ,再用 "*": "deny" 兜底:
yaml
permission:
skill:
"*": "deny"
"code-review": "allow"
"git-master": "allow"
"diagnosing-bugs": "allow"
# ...完整工具链
白名单不只是权限------被 deny 的技能不会进入该 Agent 的技能名册,所以这份名单同时也是上下文预算表。对执行器全量放行是对"内联需要全工具链"的补偿;对其他专职 Agent(只做搜索、只做审查),则应保持最小化。
工作流纪律:多步任务先写有序 TODO(每条带可验证的完成判据)、最小改动、自验证、遵守 Git 安全规范。这部分与全局规则一致,不重复。
1.6 落地时的高频修正点
- 思考档描述与真实行为对齐(见 §1.4);
- 多模态拒绝条款加"依赖会话模型"限定;
- frontmatter YAML 的值保持引号一致(bare
deny/allow在 YAML 1.1 下虽然仍解析为字符串,但混合风格在解析器严格化时就是隐患); - 内置助手的档位语义要按模型解析链写对,否则会推出"主会话会跟随某档"这类错误结论。
二、OpenCode v2:配置语义与迁移
2.1 双路径加载:一个键决定你走哪条路
v2 加载配置文件有两条路径:原生解码 ,或者------只要文件中出现任意一个 v1 触发键------官方的 V1→V2 迁移路径 。触发键包括 logLevel、server、command、plugin、small_model、mode、agent、provider、permission、tools、attachment、layout 等。
这决定了迁移策略是一个二选一,而不是模糊地带:
| 策略 | 写法 | 适用 |
|---|---|---|
| 双运行时兼容 | 只写 classic 键;v2 在加载时归一化上转 | 需要同时支持新旧两代运行时 |
| v2 原生 | 写原生键,并保证不出现任何 v1 触发键 | 已声明只支持 v2 |
两条路都可行,但不能混:混写的下场是"文件里有原生拼写,加载时却走了迁移路径",行为不由你控制。如果选择 v2 原生路线,目标就是把触发键清零------它们一个都不能残留。
2.2 键名地图
迁移的核心是把一批键换成原生拼写,并同步语义:
| classic 写法 | v2 原生 | 备注 |
|---|---|---|
plugin |
plugins |
字符串或 {package, options} |
provider.models.*.options |
...models.*.settings |
请求透传(temperature / thinking) |
provider.models.*.modalities |
...models.*.capabilities |
{tools, input[], output[]};不声明图像输入,多模态调用会被拒绝 |
provider.models.*.cost(平铺 cache_read) |
cost 数组 + 嵌套 cache: {read, write} |
|
agent / command / permission / attachment |
agents / commands / permissions(有序列表)/ media |
权限动作名同时改名:bash→shell、task→subagent |
顶层 subagent_depth |
experimental.subagent_depth |
顶层键会被 v2 直接丢弃 |
small_model |
---(展开为 title Agent 的模型) |
建议直接显式固定 title |
compaction.reserved / preserve_recent_tokens |
compaction.buffer / compaction.keep.tokens |
prune/tail_turns 是 v1 概念,v2 不再读取 |
一个务实的迁移细节:没必要一次迁移所有层。譬如 provider、权限、命令这些顶层键迁移并验证完毕后,Agent 文件的 frontmatter 可以暂时保留旧写法(v2 实测接受),等下一轮单独迁移、单独验证。一次变更只动一类东西,是这种"静默失效"平台上最划算的纪律。
2.3 权限模型:有序规则 + last-match-wins
v2 的权限是一张有序规则表 ,解析语义是 最后匹配者胜(last-match-wins)。这直接决定了规则的书写顺序:
jsonc
"permissions": [
{ "action": "shell", "resource": "*", "effect": "ask" }, // catch-all 放最前
{ "action": "shell", "resource": "git status*", "effect": "allow" }, // 后面的规则覆盖它
{ "action": "shell", "resource": "rg *", "effect": "allow" },
{ "action": "read", "resource": ".env", "effect": "deny" }
]
两个直接推论:
- catch-all 必须放最前,具体规则跟在后面覆盖它。 顺序放反(catch-all 在最后),所有写在它前面的 allow 规则全部被遮蔽------配置看起来"写了 allowlist",实际全部落到 ask,且不报任何错。
- 在 catch-all 已是
ask的体系里,显式罗列危险命令(如rm -rf*、git push --force*)是死规则。 它们匹配的结果与 catch-all 相同,纯粹是维护负担。与其维护黑名单,不如让默认值兜底:默认 ask(而不是默认放行),让任何"未识别的破坏性变体"自动落到人工确认。
2.4 静默失效清单(迁移前必读)
v2 配置世界里"写了不等于生效"的模式,值得形成一张清单:
| 模式 | 行为 |
|---|---|
| 未知键 | 两种运行时都静默丢弃,不报错 |
| 单复数 | 只认单数形式(如 skill、attachment);复数拼写被忽略 |
| 规则顺序 | last-match-wins;顺序错了会把 allowlist 变死配置 |
| v1/v2 混写 | 出现一个 v1 触发键即切换到迁移路径,原生拼写行为不受控 |
| YAML 严格性 | v2 的 frontmatter 解析是严格 YAML:description 里未引号的 ": " 会导致解析失败,该 Agent 回退为"整个文件当 system prompt + 默认配置"(丢 model / steps / 权限)------在宽松解析的旧运行时上完全不可见 |
| 改名键残留 | 如 autoupdate→update 之类的旧名残留,不报错、不生效 |
验证方法(唯一可靠的做法) :权威是二进制,不是文档。用 debug config / debug agents 之类的命令把归一化后的实际解析结果 打印出来,迁移前后做字段级 A/B 对比(commands、取值、权限顺序逐项相同);再用一次真实调用对账(例如计费与离线估算吻合)。"配置文件看起来没问题"在这个平台上没有任何证据强度。
2.5 模型解析链:agent.model 到底管谁
这是最容易误解、且直接影响 solo 正确性的一块。结论:
agent.model 只在这三处生效:
① 子会话(subagent,以及绑定 subagent 档的斜杠命令------命令会创建子会话,
按该 Agent 的档位运行,当前主会话的模型不受影响)
② title / summary / compaction 这类一次性调用
③ 界面新会话的随 Agent 联动默认值
主会话自身的解析顺序:
-m 参数 > 会话已存模型 > 全局默认模型
(headless 运行不读取 agent.model------想降档必须显式指定)
对 solo 的含义:task: deny 是第一道闸,内置助手是第二道闸,而解析链认知是第三道闸。前两道不齐,或误以为"主会话会跟随某 Agent 档位",都会让"全程单模型"的结论失真。
2.6 压缩窗口:把隐式默认值改成显式预算
内置上下文压缩由 limit.input 这一个开关决定触发点:
触发条件:会话 token 数 ≥ limit.input − compaction.buffer
不声明 limit.input 时:触发点回退为 context − maxOutputTokens
回退值可能非常离谱:以一个 1M 上下文、384K 输出上限的模型为例,隐式触发点是 968K tokens ------也就是说会话可以膨胀到近百万 token 才第一次压缩。而这类 API 的计费模型是每个请求重发整个上下文:携带 968K 与携带 115K,成本差接近一个数量级。所以:
- 显式声明每个模型的
limit.input,把"工作窗口"变成预算数字(例如给高频模型设 128K 窗口、给深度模型设 160K 窗口); - 理解配套语义:
buffer是压缩期间的溢出余量,keep.tokens是压缩后保留的 verbatim 尾部(其余更早内容被摘要替换); - v1 的
prune/tail_turns在 v2 不生效,reserved/preserve_recent_tokens改名后被读取------迁移时必须逐键核对。
在纯配置体系里,显式压缩窗口是最大的单点成本杠杆:前缀越小,缓存命中率越高、压缩触发越晚,收益叠加。
三、把方法论压成几条
- 结构性保证优于行为性请求。 权限层拒绝 > 提示词请求;"不可能发生" > "尽量别做"。
- 单模型保证是一个清单,不是一句话。 权限闸(no delegation)+ 助手闸(no built-in helpers)+ 解析链认知(agent.model 不约束主会话),缺一不可。
- 全局规则需要角色级豁免通道。 当全局规则与角色目标冲突时,把豁免写进角色定义,别让模型临场权衡。
- 默认值即成本。 压缩窗口的隐式触发点、权限的默认放行、技能名册的常驻开销------每一个"平台默认"都应该被审视并显式化。
- 静默失效平台上,验证必须对着二进制。 A/B 对比归一化输出 + 真实调用对账;文档和"看起来对"都不算证据。
- 迁移分阶段、分层。 先兼容后纯化(或反之),一次只迁移一类已能独立验证的东西。