先说一个场景。前阵子帮一个团队做内部Agent平台的配置评审,会议室里坐了七八个人,运维、安全、平台、业务各来一位。项目负责人打开配置文件,第一页写着"人格:破甲模式;技能:全量放开;工作流:多域串联"。我问他一句话:这套配置上线之后,如果某天它自作主张多做了一步,你从哪份日志里能看出来它为什么多做那一步?会议室安静了大概十秒。
这个问题几乎命中了所有"指令解析型Agent"的通病:大家把精力全花在"它能不能做"上,几乎没人认真设计"它凭什么做、做到哪一步停、做完留下什么痕迹"。这篇就围绕这套内部的Codex-X框架里那个被叫做"海鸥3.0破甲"的配置包,掰开讲讲人格设定、技能路由与多域任务工作流这三件事在工程上到底该怎么落地。核心关键词是Agent指令解析、人格设定、技能路由和任务工作流编排。适合正在做Agent平台配置、在做授权范围内的安全演练自动化、或者单纯被"提示词写着写着就飘了"折磨过的同行看,零基础的也能跟下来,因为我会把每个字段为什么这么设讲清楚。
需要提前说明一条底线:本文讨论的全部是授权范围内、可审计、可回滚的防御性验证与流程编排,不涉及任何未经授权的系统探测手法。所有示例都是结构层面的配置写法,不包含可直接执行的攻击载荷。
1. 指令解析层到底在解析什么
很多人对"指令解析"的理解停留在"把用户的话翻译成函数调用"。这只是最表层。在一套成熟的Agent框架里,解析层实际承担的是四件事:理解意图、确定角色、选择技能、约束边界。这四件事任何一件缺位,后面都会以"Agent乱来"的形式爆发出来。
1.1 一句话进入系统后的四次变形
用户在对话框里敲下一句"帮我看看这个服务对外暴露的接口有没有配置疏漏",这句话在解析层会经历四次变形。
第一次,意图归一化。系统要把它从自然语言压成一个意图标签,比如surface_review。这一步的关键不是准确率有多高,而是必须有一个明确的兜底意图。我见过太多配置只写了二十个意图标签,没有unknown分支,结果模型遇到陌生输入就开始硬凑,凑到最相近的那个标签上,然后兴高采烈地执行了一个完全不相干的任务。
第二次,角色绑定。同一个意图,在不同人格下执行方式完全不同。评审模式下它应该只读、只报告;演练模式下它可以发起受控的验证动作;复盘模式下它应该完全不碰目标,只处理已有数据。所以角色不是装饰性的提示词前缀,它是权限的载体。
第三次,技能选路。意图加角色决定了可用技能集合的候选范围,路由表再从中选出具体的一到两个技能。注意是"一到两个",不是"全部"。技能给多了,模型会在多个工具之间反复横跳,这叫工具过载,是小模型和中等模型最常见的失效模式。
第四次,边界裁剪。这一步经常被整个跳过。边界裁剪的含义是:把目标范围、时间窗口、动作上限、人工确认点这几个约束,作为结构化字段注入到后续每一次调用里,而不是只写在系统提示词里靠模型"记得"。写在提示词里的约束是软约束,注入到调用参数里的约束才是硬约束。
我一般会在配置文件里明确写出这个四段式,任何一个字段缺失就拒绝加载。这听起来很硬,但比线上出事故之后再补要便宜得多。
1.2 为什么人格设定必须写在路由之前
这是个顺序问题,也是我认为这套框架设计里最容易搞反的一处。
绝大多数人的写法是:先写路由,把技能表列全,然后在每个技能调用时临时拼一段人格描述。这种写法短期能跑,长期一定会崩,原因是人格是约束的来源,不是约束的应用对象。你先确定了"我现在是一个只读评审员",路由才可能知道"扫描类技能全部不可用";你先确定了技能,再回头补人格,人格就变成了事后解释,而不是事前限制。
举个具体例子。海鸥3.0这套配置里有一份persona.yaml,它被加载的时机在所有技能注册之前。文件里有一条forbidden_actions,写着几类动作在评审人格下永久禁用。这份文件加载完之后,路由表在构建候选技能池的时候,会直接把这些动作对应的技能从池子里剔除,不是标记为禁止调用,是不进池子。这个区别很关键:标记为禁止,模型还是看得见它,还是有可能在被诱导时去试;不进池子,模型根本不知道有这个东西存在。
提示:判断一套Agent配置是否成熟,一个很简单的办法就是看它的人格文件有没有
forbidden_actions字段。只有性格描述没有人格边界的配置,本质上只是一段角色扮演提示词。
2. 人格卡片:把"角色扮演"写成可校验的契约
"你是一位资深的安全专家,请谨慎地……"——这种写法我称为形容词人格。它的特点是读起来很专业,跑起来很不稳定。原因很朴素:形容词没有判定标准,模型每次对"谨慎"的理解都可能不一样,温度参数稍微一抖,行为就漂了。
工程上可用的写法是契约型人格:每一句描述都对应一个可观察、可判定的行为。
2.1 人格卡片的最小字段集
我在海鸥3.0这套配置里推行的是九个字段的结构,缺一不可。直接给结构:
persona: id: reviewer_readonly display_name: 只读评审员 mission: > 在授权范围内对指定目标做配置层面的表面评估, 输出结构化发现清单,不执行任何变更动作。 allowed_domains: [surface, config, dependency] allowed_actions: - read_metadata - enumerate_readonly - diff_compare forbidden_actions: - any_write - any_auth_attempt - any_payload_delivery evidence_policy: every_claim_needs_source stop_conditions: - target_out_of_scope - confidence_below: 0.6 - human_review_required output_schema: finding_list_v2 handoff: escalate_to_human九个字段分别是:标识、展示名、使命、可操作域、允许动作、禁止动作、证据策略、终止条件、输出结构与交接方式。我重点讲三个最容易被省略的。
证据策略。every_claim_needs_source这一条的要求是:模型输出的每一条结论都必须挂一个来源引用,可以是某次只读调用的返回、可以是配置文件里的行号、可以是之前的某条发现。挂不上来源的结论一律降级为"待确认"。这条规则能砍掉大概六成的幻觉输出,因为它把"我觉得"变成了"我看到了"。
终止条件。这是人格卡片里最值钱的字段。多数配置只写"什么时候开始",不写"什么时候停"。模型是很勤奋的,你不告诉它停,它就会一直找事做。stop_conditions里那三条,分别对应三种典型情况:目标越界、置信度不足、需要人判断。第三条特别重要,它给了模型一个体面的退出方式,而不是硬着头皮编一个结论出来。
交接方式。escalate_to_human定义了当触发终止条件时,任务怎么交出去、交给谁、带什么上下文。没有这个字段,Agent停下来之后就只是一个沉默的失败,运维看到的就是一个转圈圈。
2.2 人格漂移的三种典型症状
跑了几个月,我总结出人格漂移基本就三种表现形式,认出来之后对症下药很快。
第一种是语气漂移。输出突然从"发现三项配置项未启用鉴权"变成"这个系统存在严重安全风险,建议立即整改"。语气一升级,通常意味着模型开始脱离结构化输出,往自由发挥走了。这时候先查输出schema有没有被绕过。
第二种是域漂移。评审人格本来只该碰surface域,结果它开始聊依赖链漏洞。这种漂移往往不是模型的问题,是路由表把不该给的技能给了。查路由表比改提示词有效得多。
第三种是勤奋漂移,也是最隐蔽的。表现是任务范围内的事做完了,它开始"顺手"多做一点,比如顺手多扫了一个相邻网段。这类问题的根因几乎总是stop_conditions写得不够死,或者边界没有注入到调用参数层。我的做法很土:在每次工具调用的封装层里做一次硬校验,目标不在白名单里直接抛异常。宁可让任务失败,不要让它"顺手"。
注意:人格漂移不要试图用加长提示词来治。提示词越长,模型对其中每一条的注意力越稀释,漂移反而更容易发生。治漂移靠的是收窄技能池和加硬校验。
3. 技能路由表:三把钥匙决定调谁
路由表是整个配置里最像"工程"的部分,也是最能体现设计者水平的地方。它的本质是一个多条件映射:给定当前意图、当前人格、当前风险档位,选出一个技能并给出调用参数模板。
3.1 路由键的三层结构
我用的路由键是三层:意图键、域键、风险档位。
意图键是粗粒度的,比如"表面评审""依赖梳理""配置比对",通常十到二十个就够。域键决定技能属于哪个能力域,比如surface、config、dependency、log。风险档位是最容易被忽略的一层,我一般分三档:
| 档位 | 判定依据 | 默认行为 |
|---|---|---|
| L1 只读 | 不改变目标状态,不产生外部副作用 | 自动执行,事后汇总 |
| L2 受控 | 可能触发目标日志或告警,但不改变业务状态 | 执行前记录意图,执行后立即上报 |
| L3 敏感 | 可能影响目标运行状态 | 强制人工确认,默认拒绝 |
这张表的价值在于:它把"要不要人看着"这个决策从模型手里拿走了。模型不需要判断自己这步危不危险,它只需要把动作声明成某个档位,剩下的由路由层决定。这个设计思路和很多成熟系统里的权限模型是一致的——让执行者声明,让调度者决策。
路由表的配置文件我一般写成这样:
routes: - key: {intent: surface_review, domain: surface, risk: L1} skill: readonly_endpoint_probe params_template: timeout_s: 8 concurrency: 2 output: normalized_endpoint_list on_empty: report_no_finding - key: {intent: config_compare, domain: config, risk: L1} skill: config_diff params_template: baseline: last_approved ignore_keys: [timestamp, checksum] - key: {intent: surface_review, domain: surface, risk: L2} skill: null action: require_human_confirm最后一条是我特意加的:同一意图在不同风险档位下,允许路由到"不做"。这一点很多人想不到,但它是整套配置能安全跑起来的关键。因为一旦某类动作被判定为L2及以上,正确做法往往不是换个更温和的技能,而是直接挂起等人。
3.2 路由失配的两类故障
第一类:多键命中。同一个请求同时满足两条路由,系统随机或按顺序取一条。这种不确定性会让同一个输入在不同时间跑出不同结果,排查起来极其痛苦。解决办法是给路由加优先级字段,并且加载时做冲突检测——两条路由的键完全相同就直接拒绝启动。这个检查我强烈建议做成强制的,别等线上才发现。
第二类:零命中。所有路由都不匹配。这时候如果系统悄悄退化成"让模型自由发挥",那就是灾难的开始。我的处理是强制走default_deny,返回一条明确的结构化拒答,附带"当前请求未匹配任何路由"的原因说明。这条拒答会进日志,进日志之后就成了下一轮路由补全的输入。
我踩过最疼的一次坑,是零命中退化成了自由发挥。当时是一个新加的意图没配路由,模型自己拼了一套调用序列,跑了一半才被人发现。那次之后我加了一条死规则:任何未匹配路由的请求,在日志里打 ERROR 级别的标记,让它在监控面板上刺眼地跳出来。
4. 多域任务编排:状态机比流程图靠谱
多域任务最难的地方不是"能跑通一次",而是"跑一百次不偏"。画一张漂亮的流程图很容易,把流程图变成一个能约束模型的状态机要难得多。
4.1 阶段门:让每个域之间有硬隔断
海鸥3.0这套配置把多域任务拆成了若干个阶段,每个阶段之间有一道阶段门。阶段门的含义是:上一个阶段必须产出一个符合schema的产物,并且这个产物通过校验,下一个阶段才会被允许启动。
举个流程上的例子。一个完整的评审任务可以拆成:
- 范围确认阶段——产出
scope_manifest - 表面梳理阶段——产出
surface_inventory - 配置比对阶段——产出
config_findings - 依赖核对阶段——产出
dependency_findings - 汇总阶段——产出
final_report
每一道门校验的不只是"有没有产物",还有产物里的关键字段是否齐全、置信度是否达标、有没有未闭环的待确认项。第三阶段的产物里如果有置信度低于阈值的条目,第四阶段直接不启动,先回到人工确认环节。
这个设计看起来拖慢了流程,实际上省了大量时间。因为多域任务最大的成本从来不是跑得快,而是跑完之后发现前面的结论是错的,整条链要重来。阶段门就是在每个岔路口强制对账。
4.2 状态机的三张表
实现上我推荐用状态机的思路,而不是工作流描述语言。核心是三张表:状态表、迁移表、产物表。
状态表定义任务可能处于的所有状态,包括正常态和异常态。异常态一定要显式列出来,比如blocked_by_human、aborted_out_of_scope、failed_validation。我见过太多配置只定义了 happy path 的几个状态,一出异常就是隐式的 undefined,然后各种奇怪的兜底逻辑冒出来。
迁移表定义在什么条件下可以从哪个状态到哪个状态。这里的关键是禁止隐式迁移。任何一次状态变化,都必须由一条明确的规则触发,并且记录触发原因。这条规则听起来很啰嗦,但它带来的好处是:任何时候拿到日志,你都能画出这个任务的真实执行路径,而不是靠猜。
产物表定义每个状态产出什么、被谁消费、存在哪里。这张表的价值在于它把"上下文"这个模糊的东西变成了具体的、可审计的数据结构。Agent 不应该靠对话历史维持记忆,而应该靠显式的产物传递——对话历史会膨胀、会被截断、会被污染,产物不会。
提示:如果你的多域Agent需要靠"记得前面聊过什么"才能继续,那说明产物设计没做到位。把该记的东西写进结构化产物,模型的记忆负担就下来了,稳定性会明显好转。
4.3 授权边界的三道落点
在一套严肃的配置里,授权边界不能只写在文档里,必须落到三个具体位置。
第一道落在配置加载时。每个任务必须有明确的授权声明,包含目标范围、时间窗口、允许的动作档位。没有授权声明的任务,加载阶段直接拒绝。我把它做成必填字段,而不是可选项。
第二道落在调用封装层。每一次对外的调用都要过一次校验:目标在不在白名单、动作档位在不在本次授权范围内、当前是否在时间窗口内。这三项任一不满足就抛异常。这层校验和模型完全解耦,模型改不了它。
第三道落在审计日志。每次调用都记一条:谁发起的、什么意图、什么档位、目标是什么、返回摘要是什么、耗时多少。日志要能回答"为什么"这个问题,所以除了调用本身,还要记触发这次调用的上游产物ID。
三道落点凑齐了,这套东西才有可能在一个正经组织里被放行。少了任何一道,评审时都会被问到答不上来。
5. 联调与评测:怎么判断这套配置是真稳
配置写完只是开始,真正费时间的是联调和评测。这一块我踩过的坑最多,说几个实用性强的。
5.1 回归用例集怎么攒
不要等配置写完再去想测试。我的习惯是边写配置边攒用例,每加一条路由就顺手记三条用例:一条正常命中、一条边界、一条应该被拒。攒到五六十条的时候,这套东西的稳定性就大致有谱了。
用例集我一般分四组:
| 用例组 | 覆盖目标 | 判据 |
|---|---|---|
| 命中组 | 每条路由至少一条正常用例 | 调用技能正确、参数模板正确 |
| 边界组 | 越界目标、超时、空返回 | 必须触发对应终止条件 |
| 拒答组 | 无路由、档位不足、授权缺失 | 必须返回结构化拒答,不得自由发挥 |
| 漂移组 | 诱导性输入、长上下文、重复任务 | 输出schema不得被绕过 |
拒答组是最重要的一组,也是最容易被跳过的一组。大多数人只测"能不能做对",不测"该拒的时候拒没拒"。可线上出事,恰恰都是该拒没拒。
漂移组也值得说两句。我常用的一个手法是:把一个已经完成的任务原样再提交一次,看它第二次的输出是否和第一次结构一致。如果第二次开始自由发挥、加戏、或者输出结构变了,说明上下文污染或者状态没清干净。这个测试特别便宜,但特别能暴露问题。
5.2 几个反复踩到的坑
坑一:温度参数和路由稳定性是耦合的。一开始我把温度设得偏高,想着让输出自然一点,结果路由判断开始飘——同一个输入,两次跑出不同的技能选择。后来把路由决策和内容生成拆成两次调用,路由那次温度压到很低,生成那次才放开。这个拆分的收益远超预期,强烈建议试试。
坑二:产物schema改版没有版本号。早期我给产物加字段、删字段都很随意,结果下游消费方经常拿到不认识的字段然后静默忽略,问题积累到很后期才暴露。后来所有schema都带版本号,消费方必须显式声明自己支持的版本,不匹配直接报错。这个改动治好了我大半的联调痛苦。
坑三:把"重试"当成万能的。早期我给所有失败都配了自动重试,结果越界类的失败被重试了三次,日志里出现了三条越界记录,审计的时候非常难解释。后来把失败分类:可重试的(超时、临时不可用)才重试,不可重试的(越界、授权不足、校验失败)直接终止并上报。这个分类做完,日志干净了一大截。
坑四:忽略冷启动时的路由表加载顺序。有一次人格文件加载晚于路由表构建,导致forbidden_actions没生效,一批本该被剔除的技能进了池子。这个问题非常隐蔽,因为大部分时候它不影响结果,只在特定组合下才出问题。后来我在加载流程里加了显式的依赖声明和启动自检,启动时打印一份"最终生效的技能池",人工看一眼就能发现异常。
坑五:把评测当成一次性的事。模型会更新,配置会改,路由会加。我现在的做法是每次配置变更加跑一遍全量用例,用例结果和上一次做对比,任何一条结果变化都要有人解释。这个流程听起来重,但比事后回溯便宜。
我个人在实际操作中的体会是,这类Agent配置的难点从来不在"能不能写出一个能跑的版本",那部分一两天就能搞定;真正的成本在让它在半年之后、在别人手里、在没人盯着的时候,行为依然和你当初设想的一致。人格卡片、路由表、状态机、审计日志,这四样东西本质上都是同一个目标的不同侧面:把"模型的行为"从一件靠运气的事,变成一件可预期、可验证、可追溯的事。至于具体用哪种写法,各家框架不一样,但判断标准是通用的——你随便挑一次历史执行记录,能不能在五分钟内讲清楚它为什么这么走。讲得清楚,这套配置就算立住了;讲不清楚,那它现在还没出事,只是运气好。