聊一个最近在做的东西:TypeSafe Agent Skill。这名字听着有点玄,实际就是把 Agent 系统里那些“只做判断、不需要写小作文”的能力,封装成类型安全的 Skill,无论输入还是输出,全部走结构化契约,连一个多余的自然语言符号都不给。这事儿做完以后,我带团队的人复盘了两周,期间踩了四个坑,最后得到的结论特别简单:判断型任务就不该让大模型自由发挥。
这篇不是泛泛的架构介绍,而是一份工程实录。如果你正在做 Agent Skill 接入、工具选型,或者正纠结“判断类功能要不要交给大模型”,我建议往下看。我会先讲清楚为什么判断任务和文本生成是错位的,然后给出 TypeSafe Skill 的设计骨架、选型对比、完整接入代码,最后把上线后最值得记住的两个故障排查链路原样写出来。
1. Agent 里那些“判断活儿”为什么不该交给文本生成
1.1 判断型任务在 Agent 系统里的真实占比
先说一个观察。绝大多数 Agent 项目里,LLM 真正需要“发挥创造力”的场景可能只占三成,剩下七成往往是这类东西:某个操作是否放行、某个实体是否属于预设类别、两个字符串是否指向同一对象、数值是否落在阈值区间内、某句话是否触发了敏感规则。
这些任务有个共同特点——它们的结果是一个闭合集合。要么通过、要么拒绝、要么转人工复核,不存在第 101 种可能。我之前接手一个内部 Agent 平台的时候,团队习惯把这类任务全塞给大模型,让模型在 prompt 里“判断一下”然后返回一段文字。结果就是:同一类请求,上午返回“可以”,下午就变成“行吧但建议谨慎”,下游系统根本没法稳定消费。
1.2 让 LLM 做判断的三大代价
第一是结果不稳定。生成式模型的输出空间是自然语言,而自然语言对程序来说是弱契约。模型今天心情好就多写两句解释,明天升级版本就换了一种措辞,你的解析规则跟着疲于奔命。
第二是延迟和成本被白白浪费。判断型任务需要的计算量远小于生成型任务,但你让 LLM 做的时候,它照样要跑一次完整的 Transformer 推理。一次简单的权限判断,传统规则实现是微秒级,LLM 通常是几百毫秒到几秒,成本还高一个量级。
第三是下游解析的脆弱性。大家可能都用过 JSON mode 或者 function calling,但哪怕模型返回的是 JSON,字段里塞的是自然语言解释,后续做聚合、排序、告警都很麻烦。判断结果一旦需要再次被另一个模型或业务系统消费,就会有二次解析损耗。
1.3 “不生成文本,只做精准判断”的价值链
如果你把判断任务从生成任务里拆出来,就会得到一个特别干净的分工:生成任务继续让 LLM 发挥,判断任务全部下沉到类型安全、确定性的 Skill 里。这个 Skill 返回的不是“我觉得可以”,而是{ "decision": "allow", "reason": "rule#2: read-permit", "checks": [...] },每一个字段都可以被程序直接消费。
我用一个生活类比:你去办事窗口盖一个审批章,需要的不是办事员给你写一篇散文说明“结合当前形势综上所述准予办理”,而是他对照规则后给出一个明确的“同意/不同意”,再附上依据条款。判断型 Skill 就是那个盖审批章的人。
2. TypeSafe 判断型 Skill 的契约设计:类型、结构与可审计性
2.1 契约先行:输入输出类型是 Skill 的第一公民
工程选型的第一步不是选模型,也不是选框架,而是先把契约定下来。这里说 TypeSafe,包含两层含义:定义层的类型安全,也就是 TypeScript 接口或 Python 类型标注;运行时的类型安全,也就是框架层面做 schema 校验和断言,确保运行期拿到的数据一定符合定义时的期待。
我以 TypeScript 为例,这类 Skill 的标准接口长得像下面这样:
// skill.d.ts export const skillMeta = { id: "operation_access_judge", version: "2.1.0", kind: "judge-only", // 关键标识:只判断,不生成 inputSchema: "zod://operation_access_judge/input", outputSchema: "zod://operation_access_judge/output", timeoutMs: 300, // 这类任务不该跑超过几百毫秒 }; export interface OperationAccessJudgeInput { operatorId: string; action: "read" | "create" | "update" | "delete"; resourceId: string; resourceEnv: "prod" | "staging" | "dev"; context?: Record<string, unknown>; } export type JudgmentDecision = "allow" | "deny" | "review"; export interface OperationAccessJudgeOutput { decision: JudgmentDecision; reason: string; checks: Array<{ ruleId: string; passed: boolean; detail?: string; }>; meta: { skillId: string; skillVersion: string; evaluatedAt: number; }; }你注意几个细节:kind字段标记了这个 Skill 的性质是judge-only,它不声明任何生成能力,下游调度器看到这个标记就不会把它的输出喂给自然语言后处理流程。输出里的checks数组把一次判断拆成若干条规则结果,每一子项都能追溯。我要求所有判断型 Skill 强制携带meta,没有版本和评估时间的结果不允许进入生产链路。
2.2 判断逻辑放在哪:确定性子程序,不是 Prompt
很多人在这一步容易跑偏,以为“类型安全”就是给 LLM 的返回值套一层解析。其实不是。判断逻辑本身应该和 LLM 完全解耦,是一个纯函数。同一个输入,永远得到同一个输出,不依赖温度、不依赖模型版本、不依赖上下文窗口。
import { z } from "zod"; const actionRuleSchema = z.object({ action: z.enum(["read", "create", "update", "delete"]), env: z.enum(["prod", "staging", "dev"]).optional(), resourcePrefix: z.string().optional(), }); const policyRules = [ { ruleId: "R1", match: { action: "read" }, decision: "allow" }, { ruleId: "R2", match: { action: "delete", env: "staging" }, decision: "allow" }, { ruleId: "R3", match: { action: "delete", env: "prod" }, decision: "review" }, { ruleId: "R4", match: { action: "create" }, decision: "allow" }, ]; export function evaluateAccessRules( input: OperationAccessJudgeInput ): OperationAccessJudgeOutput { const checks = policyRules.map((rule) => { const passed = matchRule(rule.match, input); return { ruleId: rule.ruleId, passed, detail: passed ? "matched" : "not matched" }; }); const matched = checks.find((c) => c.passed); const decision = matched ? (policyRules.find((r) => r.ruleId === matched.ruleId)?.decision ?? "deny") : "deny"; return { decision, reason: matched ? `rule#${matched.ruleId}` : "no policy matched, default deny", checks, meta: { skillId: "operation_access_judge", skillVersion: "2.1.0", evaluatedAt: Date.now() }, }; }这种玩法的优势非常明显:你能对evaluateAccessRules做单测,能覆盖所有规则分支,能统计每一条规则命中的次数,还能在规则变更时先离线评估影响范围。换模型版本的时候,判断结果完全不受影响。
2.3 返回结构设计:Decision + Reason + Checks 三段式
我再强调一下返回结构为什么不能省。很多团队做这类 Skill 时,返回一个{ "result": "deny" }就结束了,看起来省事,但生产环境根本不够用:
- decision:机器能直接分支的结论,必须校验为三个枚举值之一,不多不少。
- reason:给人和日志看的简短解释,可以是规则 ID,也可以是策略描述,但不允许让模型自由发挥。
- checks:完整证据链,每一子项记录了哪条规则命中了、哪条没命中,方便审计和复盘。
如果某个判断涉及多级政策,比如“先查用户权限,再查资源环境,再查灰度开关”,每一级都应该往checks里追加一项。这样当决策结果和业务预期不一致时,打开日志直接能看到是第几条规则挡住了请求,而不是抓瞎。
3. 工程选型:从 LLM 直出 JSON 到 TypeSafe Skill 的取舍
3.1 四个候选方案的横向对比
这是我当时做的选型对比,直接放表格,你可以结合自己的场景看。
| 方案 | 结果稳定性 | 延迟 | 成本 | 可审计性 | 维护复杂度 |
|---|---|---|---|---|---|
| LLM 直出 JSON + 提示词约束 | 低,JSON 偶发不合法 | 高,数百毫秒起 | 高,每次调用都消耗 Token | 弱,解释内容不可控 | 低,改需求只改 prompt |
| LLM 直出 JSON + 输出校验重试 | 中,校验失败时重试 | 更高,重试叠加 | 更高 | 中 | 中 |
| 纯代码规则引擎 / 策略引擎 | 高 | 极低 | 零 | 高 | 中高,策略越来越多时难以维护 |
| TypeSafe Skill + 规则引擎混合 | 高 | 极低 | 零 | 高 | 中,但契约清晰 |
我们最后选了第四种。理由不是“规则引擎比 LLM 高级”,而是判断任务本来就该用确定性的实现。TypeSafe Skill 本质上是一个带契约外壳的规则引擎,它把规则的顺序、匹配逻辑、结果解释都绑定成一份可版本化的代码。它和纯规则引擎的区别在于:它天生以 Agent 技能的形式存在,能直接注册进 Agent 的调用链,上游不需要单独写一层编排。
3.2 为什么不能只靠“输出校验”
有一种思路看起来折中:还是让 LLM 判断,但是对输出做 JSON Schema 校验,不合法就重试。这种方案上线初期确实有效,但我劝你慎重,原因是重试本质上是在用一个不确定系统模拟一个确定系统,代价全部变成延迟和成本。
我给一组我们之前压测环境的数据,仅供参考。同一个权限判断请求,走 LLM 直出 JSON 的 P95 延迟在 1.2 秒左右;走带重试的逻辑,P95 掉到 2.8 秒,因为触发了不少重试;改成 TypeSafe Skill 之后,P95 是 4 毫秒。这个差距是本质性的,不是优化能追平的。更麻烦的是,LLM 在“判断”这件事上偶尔会脑补出不存在的上下文——比如输入里根本没有的项目名,它会在 reason 里自己编一个,这对审计来说非常致命。
3.3 类型方案落地:TypeScript + Zod,还是 JSON Schema,还是 Pydantic
落地的时候还得选具体的类型技术栈。我这边运行时是 Node/TypeScript,所以结论可能带一点栈倾向,但思路是通用的。
- 纯手写 JSON Schema:可移植性最好,但开发体验差,写起来冗余,而且它只描述结构,不提供代码级的类型推导。
- TypeScript 接口 + 运行时手写判断:开发体验好,但类型和运行时校验是两套东西,容易漂移,后文故障二就是这个问题。
- TypeScript 接口 + Zod Schema:我最终选的方案。Zod 可以从 schema 反推出 TS 类型,也能通过框架编译成 JSON Schema 暴露给其他语言的服务。这样一份定义同时覆盖编译期和运行期。
如果你在 Python 栈,Pydantic v2 是类似的角色,核心思路完全一致:让 schema 成为类型系统的源,而不是反过来。
3.4 边界:什么任务仍然应该交给 LLM 生成
选了 TypeSafe Skill 不代表所有任务都往上塞。我会留一个判断标准:如果任务的结果集合不是预先可枚举的,或者需要根据上下文重新组织语言,那就应该让 LLM 去做。典型的例子是写周报、总结对话、生成代码片段。这些任务的结果没有闭合集合,类型安全反而会限制表达。
而反过来,只要结果是“是/否/转人工”或者“枚举 A/B/C”,哪怕逻辑再复杂,也值得用判断型 Skill 来做。判断型 Skill 不排斥复杂逻辑,它可以调用其他内部服务、查数据库、读配置,但最终出口仍然是三个枚举值。
4. 接入实录:Skill 注册、调用链路与降级策略
4.1 Skill 注册表与元信息声明
接入的第一步是把 Skill 注册进 Agent 调度器的技能表。我用了一个很薄的注册函数,它只做三件事:登记元信息、存输入输出 schema、绑定 handler。
// skill-registry.ts import { z } from "zod"; const OperationAccessJudgeInputSchema = z.object({ operatorId: z.string().min(1), action: z.enum(["read", "create", "update", "delete"]), resourceId: z.string().min(1), resourceEnv: z.enum(["prod", "staging", "dev"]), context: z.record(z.string(), z.unknown()).optional(), }); const OperationAccessJudgeOutputSchema = z.object({ decision: z.enum(["allow", "deny", "review"]), reason: z.string(), checks: z.array( z.object({ ruleId: z.string(), passed: z.boolean(), detail: z.string().optional(), }) ), meta: z.object({ skillId: z.string(), skillVersion: z.string(), evaluatedAt: z.number(), }), }); export const skillRegistry = { "operation_access_judge": { meta: skillMeta, inputSchema: OperationAccessJudgeInputSchema, outputSchema: OperationAccessJudgeOutputSchema, handler: evaluateAccessRules, }, } as const;这个注册表是整个接入过程的关键。之后无论是做测试、生成 API 文档、还是给 Agent 框架生成 function calling 定义,都从这一份注册表取数,不让信息散落在多个文件里。
4.2 接入 Agent 调用的两种姿势
判断型 Skill 进了注册表之后,接入 Agent 主循环一般有两种姿势。
第一种是作为 function calling 的工具暴露,Agent 在推理过程中通过工具名调用它。我们需要把输入 schema 转成 OpenAI 风格的工具参数格式。
function toToolSchema(schema: z.ZodTypeAny): object { // 项目里封装了 zod 转 OpenAPI Schema 的工具函数 return convertZodToOpenApi(schema); } const agentTool = { type: "function", function: { name: "operation_access_judge", description: "判断指定用户对某资源操作是否被允许。仅处理程序化操作判断,不回答开放式问题,不生成解释性文字。", parameters: toToolSchema(OperationAccessJudgeInputSchema), }, };第二种姿势是作为 Agent 内部的“前置闸门”。也就是在真正调用某个生成型 Skill 之前,先跑一轮判断型 Skill。比如用户请求触发了删除操作,先调用operation_access_judge,如果结果不是 allow,就直接拒绝,根本不给 LLM 生成后续内容的机会。这比“先生成完了再审核”更省成本。
4.3 调用入口的运行时防护
接入过程中最容易忽略的是运行时防护。我在注册表的handler外面包了一层通用的执行壳子,它干四件事:超时控制、输入校验、输出校验、错误归一化。四类任何一项不通过,都不允许把结果抛向上游。
async function runJudgeSkill( skillId: keyof typeof skillRegistry, input: unknown ): Promise<OperationAccessJudgeOutput> { const skill = skillRegistry[skillId]; const parsedInput = skill.inputSchema.safeParse(input); if (!parsedInput.success) { throw new JudgeSkillValidationError(skillId, parsedInput.error); } const output = await Promise.race([ Promise.resolve(skill.handler(parsedInput.data)), timeout(skill.meta.timeoutMs), ]); const parsedOutput = skill.outputSchema.safeParse(output); if (!parsedOutput.success) { // 这里是关键:handler 返回了不符合契约的结果,宁可报错也不要放行 throw new JudgeSkillOutputContractError(skillId, parsedOutput.error); } return parsedOutput.data; }这里有一个非常执拗的设计选择:当输出校验不通过时,一律按异常处理,不允许“先放行再观察”。判断型 Skill 的价值全在确定性,如果输出类型都不稳定,它的存在就没有意义了。宁可让这条链路报错,让监控告警,也不要把一个结构异常的结果当成“成功”写进业务系统。
4.4 降级与失败策略的取舍
判断型 Skill 本身很轻,但它的调用链可能会依赖外部配置、规则服务,偶尔也会失败。降级策略我坚持的是:权限类判断失败默认 deny,非关键分类类判断失败允许降级为 review,而不是 allow。你可以按照自己的业务性质调整,但请一定记住:不要因为“怕影响用户”就把所有失败都当成放行,这会在安全类场景里变成灾难。
如果是纯本地规则的判断型 Skill,它不需要任何网络调用,失败概率极低,你甚至可以不做降级只做重试。我们最终全部采用了本地规则求值,把规则文件版本化后随服务发布,彻底避免了运行时远程拉取的故障可能。
5. 上线实测与两个让我印象深刻的坑
5.1 预期收益与实际表现
接入完成后,我把这类 Skill 统一走了一轮灰度。先只接入“操作权限判断”这一个技能,观察一周,再逐步扩大范围。从压测环境和灰度的数据来看,效果符合预期:
| 指标 | 改造前(LLM 文本判断) | 改造后(TypeSafe Skill) |
|---|---|---|
| P95 延迟 | 约 1.2 秒 | 4~6 毫秒 |
| 返回结构稳定率 | 92% 上下 | 100% |
| 单次调用成本 | 折算 Token 费用高 | 可忽略 |
| 审计追溯耗时 | 需要人工翻 prompt 记录 | 直接看 checks |
但这东西真正上线以后,我记住的不是这些数字,而是两个和预期完全相反的意外。它们都和“类型安全”这件事有关,一个讽刺,一个隐蔽。
5.2 踩坑全链路一:运行时 Schema 和编译期类型漂移了
故障现象是一次线上告警:某个删除操作被预期放行,但实际返回了 deny,业务反馈说“判断结果和预期不一致”。我沿着日志排查,逐步还原整个链路。
[14:03:22.112] skill execution started: operation_access_judge [14:03:22.113] input parsed: { operatorId: "U-1024", action: "deleteItem", resourceId: "res:stag:item:88", resourceEnv: "staging" } [14:03:22.115] rules evaluating... [14:03:22.116] rule#1 (read): not matched, action="deleteItem" [14:03:22.116] rule#2 (delete in staging): not matched, action="deleteItem" [14:03:22.116] rule#3 (delete in prod): not matched, action="deleteItem" [14:03:22.117] result: { decision: "deny", reason: "no policy matched, default deny" }第一眼看上去,是规则没匹配上。为什么没匹配上?因为输入里的 action 是deleteItem,但策略规则里写的是delete。有人把上游请求字段从delete改成了deleteItem,却只改了调用方代码,没有改规则表。更讽刺的是,这个输入竟然通过了 Zod 校验。
问题根源就在这:action 字段用的是z.string(),没限定枚举。上游传deleteItem的时候,定义层的 TypeScript 接口还停留在"delete" | "create" | ...,但运行时校验却接收了一个越界值。换句话说,编译期类型和运行时 schema 没有同源,契约已经分裂了却不自知。
修复有两步。第一步,把 action 升级成z.enum(["read", "create", "update", "delete"]),同时增加preprocess做归一化:把上游的deleteItem、delete_item等变体统一对齐成delete。第二步,补了一条回归用例,专门覆盖“别名输入被归一化后再匹配规则”的场景。这个坑给我的教训是:只要有手写的 DTO 类型和手写的运行时 schema 同时存在,漂移是迟早的事。后来我把所有判断型 Skill 的 schema 声明作为唯一事实源,TS 类型一律通过z.infer推导,不再手动重复声明一次。
5.3 踩坑全链路二:判断型 Skill 被 Agent 当成闲聊工具误调用
第二个坑更隐蔽。接入判断型 Skill 一段时间后,我们发现某些会话里 LLM 会把开放式问题也丢给operation_access_judge处理。比如用户问“你觉得这个需求要不要做”,LLM 居然生成了一个调用,填的参数是{ action: "needs_review", resourceId: "the-requirement" }。
从调用链来看,问题出在工具描述太宽泛。我在 description 里写了“判断指定用户对某资源操作是否被允许”,还补充了一句“不回答开放式问题”,但 LLM 对“是否”两个字特别敏感,它看到用户的问题里带着“要不要”,就会尝试套用这个工具。
排查链路从对话日志开始:
[16:20:01.401] llm tool_call generated: operation_access_judge [16:20:01.402] args: { operatorId: "USER_SAYING_NEEDS_REVIEW", action: "needs_review", resourceId: "the-requirement", resourceEnv: "prod" } [16:20:01.405] zod validation failed: action not in enum [16:20:01.405] tool error returned to llm: invalid action enum接下来 LLM 可能会把报错当成“参数不对,我再试一次”的信号,继续生成新的非法参数,形成循环重试。修复不是硬编码“禁止调用”,而是做了三件事,让工具边界足够清晰:
- 给
action参数加枚举,不合法参数直接校验失败,从根源阻断编造; - 重写工具描述,强调“仅在已获得操作动作枚举和资源标识时调用,禁止推断动作语义”;
- 在 Agent 调度层加了一个前置过滤器:如果用户消息里没有对应的资源标识特征,就直接不向 LLM 暴露这个工具。
这算是我最推荐的一种治理姿势——不要在提示词里反复哀求模型“不要乱调用”,而是从参数约束和曝光范围上让它没有乱调用的机会。判断型 Skill 是给程序调用的确定性接口,不是给 LLM 用来展示语义理解的花哨工具。
5.4 灰度与回归防护:contract test 和 dryRun 模式
上线稳定以后,我把判断型 Skill 的测试维护分成了三层。第一层是单测,覆盖每条规则的分支;第二层是 contract test,专门验证“注册表里的 schema 和 handler 实现是否一致”;第三层是线上 dryRun,新 Skill 上线前先跑一段日志模式,不实际影响决策,只记录“如果当时生效会怎样”。
dryRun 模式大概是这样的:
// judge-runner.ts async function runJudgeSkillWithDryRun( skillId: keyof typeof skillRegistry, input: unknown, opts: { dryRun?: boolean } ) { const start = Date.now(); const output = await runJudgeSkill(skillId, input); if (opts.dryRun) { logJudgeResult({ skillId, input, output, latencyMs: Date.now() - start, dryRun: true, message: "dry-run mode: decision not applied", }); } return output; }新技能切换到正式模式前,我们要求 dryRun 的结果和人工标注的预期对齐率达到 99% 以上,并且所有“拒绝”类决策都要有人抽查 reason 是否符合规则语义。这套流程看起来笨,但它能拦住大部分类型漂移和规则边界问题。
6. 关于这类 Skill,我最后想分享的实际心得
经历这一轮接入,我最大的体会是:TypeSafe Agent Skill 的难点从来不在写代码,而在你敢不敢把“判断”这件事从大模型手里夺回来。很多团队不敢这么做,是因为总觉得“LLM 判断更智能”,但实际业务里,判断任务的核心诉求是确定、可审计、可回溯,这恰恰是生成模型的弱项。
如果你接下来也要做类似改造,我提三个可以复用的经验。第一,所有判断型 Skill 返回的 reason 字段禁止使用自然语言模型自由生成,宁可让 reason 是“rule#R2”也不要一句 AI 味十足的长解释。第二,contract test 必须和 Skill 注册表绑在一起,类型定义和运行时 schema 只允许一种写法,我用的是 schema 反推类型。第三,新 Skill 上线前必须跑满一个观察周期的 dryRun,不要觉得本地测过了就可以直接全量。
最后再分享一个小技巧:我在开发阶段会给每个判断型 Skill 暴露一个/__debug接口,你直接把原始输入喂进去,它会把输入校验结果、逐条 rule 匹配结果、最终 decision 三段式展示出来。这个接口对排查线上问题帮助极大,很多“判断结果诡异”的工单到最后都是在这个接口上三分钟定位完的。如果你也在做同类体系,建议从第一天就把它加上。