一份声明,多份产物:飞书 SDK 的 AI 工具面是怎么生成的

飞书 SDK 有 1228 个方法,我们只把其中 351 个做成了 AI 工具。这些工具没有写进 JSON 配置,也没有在运行期用反射扫出来,而是写成 351 个接口声明,由 Roslyn 源生成器在编译时产出工具描述、参数类、注册器和文档。下面讲这条生成链路怎么运转,以及为什么值得为它配 64 个契约测试。

0. 起因

把 1228 个方法全部暴露给模型,会同时撞上三个问题。

上下文装不下。现在 351 个工具的描述合计 47,495 个字符,已经需要精打细算;1228 个方法放到候选集里,模型选错的概率也会明显上升。

权限收不住。删除、转移、改权限这类方法会直接进入模型的可选范围。默认最小权限在"全都给"的形态下没法表达。

变更没人管。工具面的内容每天都在被模型读取,但没人能回答"昨天模型能看到什么,今天多了什么"。没有 diff 就没有评审。

所以我们关心的不是"怎么让模型调 API",而是怎么把模型能看到的那部分系统做成一份能生成、能评审、能回滚的产物。

口径 数字 出处
SDK 接口方法总数 1228 FeishuToolMethodCatalog.SdkMethodCount
已策展工具 351 golden 快照 351 行(185 只读 + 166 写)
业务域 34 工具名第一个 . 之前的部分
高危写工具 39 方法名命中危险词表
契约测试文件 64 Tests/Mud.Feishu.AI.Tools.Tests/ContractGuards/

1. 五层结构

这条链路里,人只碰两处:写声明和写执行器,一共 4 个手改点。其余 6 个环节全部由生成器产出,包括注册器、参数类、Schema、契约表、DI 方法和快照内容。

环节 手写 / 生成 原因
工具声明 手写 选哪个能力、参数怎么切,是产品判断
执行器 手写 返回哪几个字段给模型,是逐字段的业务意图
域注册器 生成 纯机械聚合,手写必漏
Args 参数类 生成 与声明同源,手写会漂移
Schema 生成 模型可见面的唯一来源
契约表 生成 漏字段直接在编译期报错
DI Core 方法 生成 每个执行器一条注册链
golden 快照 机械重固化 人只评审 diff,不手写内容

2. 工具写在接口上

2.1 一个声明的样子

csharp 复制代码
[AgentTool("calendar.create_event",
    Description = "在指定日历上创建一个日程(start/end 为 RFC3339 时间...)。写操作:需 calendar:calendar。",
    RequiredScopes = ["calendar:calendar"],
    IsWrite = true,
    Source = nameof(IFeishuTenantV4CalendarEvent) + "." + nameof(IFeishuTenantV4CalendarEvent.CreateCalendarEventAsync))]
public interface IFeishuTenantCalendarCreateEventTool
{
    Task<string> CreateEventAsync(
        [ToolParameter("calendar_id", "日历 ID(形如 feishu.cn_xxx@group.calendar.feishu.cn)", Required = true)] string calendar_id,
        [ToolParameter("summary", "日程标题", Required = true)] string summary,
        [ToolParameter("start", "开始时间(RFC3339...)", Required = true)] string start,
        [ToolParameter("end", "结束时间(RFC3339,须晚于 start)", Required = true)] string end,
        [ToolParameter("idempotency_key", "幂等键(可选)...")] string? idempotency_key = null,
        [ToolParameter("dry_run", "仅预演不创建(可选)...")] bool? dry_run = null,
        CancellationToken cancellationToken = default);
}

看着只是给方法加了几个注解,但这几十行同时决定了四件事:模型看到什么描述、Schema 里有哪些字段、执行器拿到什么参数、白名单按什么身份过滤。下面分开说。

2.2 为什么用接口

接口里没有方法体,声明就只能是声明。执行器换 SDK 调用、改返回字段、重写错误处理,都不影响声明;反过来改一个参数描述,也不用动执行器。

还有一点是测试上的便利:生成器只读声明,不要求执行器存在。测试工程可以引用同一个剖面,单独生成一份自己的 Schema 去比对。

2.3 Source 用两段 nameof 拼

csharp 复制代码
Source = nameof(IFeishuTenantV4CalendarEvent) + "." + nameof(IFeishuTenantV4CalendarEvent.CreateCalendarEventAsync)

拼出来的字符串是 "IFeishuTenantV4CalendarEvent.CreateCalendarEventAsync",和以前写字面量的结果一样。区别在于 SDK 方法改名时,IDE 的重命名会跟着改这里。生成器还会校验这个符号真实存在(MUDFT019),写错在编译期就断,不会等到运行期才发现工具调不通。

要注意一个语言细节:nameof(接口.方法) 只产出方法名,接口名会丢。必须写成两段再拼,否则 MUDFT019 直接报错。

2.4 参数平铺

参数都摊在方法签名上,不做嵌套对象。

声明 生成的 Schema
[ToolParameter("calendar_id", ..., Required = true)] string calendar_id {"type":"string","description":"..."},进入 required
[ToolParameter("limit", "...")] int? limit = null {"type":"integer","description":"..."},不在 required 中
[ToolParameter("attendee_ids", ...)] string[] attendee_ids {"type":"array","items":{"type":"string"}}

跨参数约束用 AnyOf 表达,比如"user_id 和 room_id 至少给一个":

csharp 复制代码
[AgentTool("calendar.find_free_slots", ..., AnyOf = ["user_id|room_id"])]

生成到 Schema 里是 anyOf。这类约束写在声明上,模型在选参数时就能看到,不用等执行器拒绝一次再猜。

2.5 风险分级

text 复制代码
IsWrite = true            → 写类工具(必须走写白名单 + 审批标记)
方法名命中危险词表        → 高危写(high-risk-write)
  WriteVerbKeywords = "delete|remove|transfer|cancel|revoke|resign|
                       permission|reset_secret|password|dismiss|purge|wipe"

判断依据是 SDK 方法名的 snake_case 子串,不是工具名。工具名可以起得人畜无害,方法名不会。这样加新工具时,不需要靠人记得去标风险等级。

2.6 为什么不直接扫全 SDK

做法 结果
反射扫描全部 IFeishu* 方法 得到 1228 个工具,等于没策展
运行期读 JSON 配置 配置和代码两套来源,改了调用不会改配置
XML 注释驱动 注释不是契约,装不下 IsWrite / Source / Scopes

3. 剖面:飞书的命名只出现在一个文件里

3.1 一个文件装下所有命名事实

生成器本身不认识飞书。它只认一份剖面,也就是一组描述"这个 SDK 长什么样"的字符串和正则。

csharp 复制代码
[SdkToolProfile(
    "Feishu",

    // 1. 特性识别(扫描目标)
    ToolAttributeName = "AgentTool",
    ToolAttributeNamespace = "Mud.AI.Abstractions.Tools",
    ToolHandlerAttributeName = "ToolHandler",
    ToolHandlerAttributeNamespace = "Mud.AI.Abstractions.Tools.Handlers",

    // 2. 源符号解析
    SdkNamespaceRoot = "Mud.Feishu",
    SoftAbsentNamespaceRoots = "Mud.AI",
    InterfaceNameRegex = @"^IFeishu(?:Tenant|User)?V\d+(?<domain>[A-Z][a-zA-Z0-9]*)(?<resource>[A-Z][a-zA-Z0-9]*)?$",

    // 3. 令牌身份推导
    TokenKindStrategy = TokenKindDerivationStrategy.NamePrefix,
    TokenKindMarkers = "IFeishuTenant=Tenant;IFeishuUser=User",

    // 4. 产物前缀 / 诊断
    ProductPrefix = "FeishuTool",
    ProductPluralPrefix = "FeishuTools",
    DiagnosticPrefix = "MUDFT",
    WriteVerbKeywords = "delete|remove|transfer|cancel|revoke|resign|permission|...",

    // 5. 产物落点
    SchemaExtensionKey = "x-feishu",
    OwnerAssembly = "Mud.Feishu.AI.Tools",
    GeneratedNamespace = "Mud.Feishu.AI.Tools.Generated",
    RiskEnumFullName = "Mud.Feishu.AI.AgentTools.FeishuToolRisk",
    ResultTypeName = "ToolResult",
    BindingTypeName = "FeishuToolBinding",
    OutputWrapperTypeNames = "FeishuApiResult|FeishuApiPageListResult|...",

    // 6. 资产目录
    GuidanceDirectory = "/Guidance/",
    AffordanceDirectory = "/Affordance/")]
internal sealed class FeishuToolProfile : ISdkToolProfile { }

3.2 这些槽位写错了会怎样

组 槽位 写错的后果
特性识别 ToolAttributeName / ToolHandlerAttributeNamespace 生成器零产出,构建仍然成功
源符号 SdkNamespaceRoot / InterfaceNameRegex 接口扫不到,工具静默变少
令牌身份 TokenKindStrategy / TokenKindMarkers 身份推导错,白名单放错类
产物落点 GeneratedNamespace / RiskEnumFullName CS0246 / CS0103
诊断 DiagnosticPrefix / DiagnosticCategory 诊断口径分叉,门禁失效
资产 GuidanceDirectory / AffordanceDirectory 资产收不到,golden 少一整个键

除了产物落点那几个会直接编译失败,其余多数是"构建通过但结果不对"。这种错最难查,所以 FeishuToolProfileContractGuards 用反射把每个槽位都钉住了。

3.3 身份从接口名推导

身份不写在 [AgentTool] 上。租户级接口和用户级接口本来就是 SDK 的事实,让工具作者再标一次只会标错。

推导结果会同时被白名单、令牌预检和执行链使用。只推一次,就不会出现"Schema 写 tenant、执行链按 user"的情况。名字里没有标记的接口(比如 IFeishuCalendarXxxTool)会落到默认 tenant,MUDFT016 会在编译期把这类不一致拦下来。

3.4 可选包没装,不算配置错误

text 复制代码
SdkNamespaceRoot         = "Mud.Feishu"   // 硬依赖:找不到就是错
SoftAbsentNamespaceRoots = "Mud.AI"       // 软依赖:缺席 ⇒ 该域工具不注册

比如知识检索器 Mud.AI.Abstractions.Knowledge.IRetriever 是可选的中立契约。宿主没装它,knowledge.* 工具就不进注册表,不报错,也不会留一个调用必失败的入口。等到白名单里真写了这些工具,装配期会明确报"未注册"。

不能图省事把 SdkNamespaceRoot 放宽成 "Mud"。它还负责按程序集名找 SDK,放宽后会去找一个叫 Mud 的程序集,结果是静默零产出。正确做法是加一个新槽位。

3.5 换一个 SDK 要改什么

4. 一次编译,产出九类文件

4.1 引擎以 Analyzer 包引入

xml 复制代码
<PackageReference Include="Mud.AI.Generator" Version="1.0.2"
                  OutputItemType="Analyzer" ReferenceOutputAssembly="false" />

<AdditionalFiles Include="FeishuToolSchemas.golden.txt"
                 Condition="Exists('FeishuToolSchemas.golden.txt') and '$(FeishuToolRefreeze)' != 'true'" />
<AdditionalFiles Include="Guidance\**\*.md" />
<AdditionalFiles Include="Affordance\**\*.md" />
<CompilerVisibleProperty Include="FeishuToolCatalog" />
<FeishuToolCatalog>true</FeishuToolCatalog>

ReferenceOutputAssembly="false" 表示它只是编译期工具,不进运行期依赖。golden、guidance、affordance 都通过 AdditionalFiles 传给引擎,编译期读取,运行期不做文件 IO。能力目录的开关走 CompilerVisibleProperty,没有为此新增配置节。

4.2 产物清单

产物 形态 谁在用 设计要点
FeishuToolSchemas 工具名 → 完整信封 JSON 工具桥、golden 门禁 模型可见面的唯一来源
FeishuToolNames const string + All / WriteAll 白名单、守卫、执行器 名字不会拼错
FeishuToolContracts record FeishuToolContract(...) 注册器、目录、守卫 运行期不解析 JSON
FeishuToolMethodCatalog 1228 条方法级目录 feishu.schema_read 含未策展方法
FeishuToolArgs 每工具一个参数类 + Unpack() 执行器 参数名只写一次
FeishuToolDomainRegistrars 每执行器一个注册器 DI 手写注册器全部删掉
FeishuToolsServiceCollectionCoreExtensions 每域一个 Add...Core() 公开 DI 入口 装配不留手工清单
FeishuToolGuidance 域全文 + 主题引用 指令面、MCP 与工具面同一 pass
FeishuToolCapabilityCatalog 内部聚合事实 自检、测试 internal,模型看不到

4.3 一个工具的完整链路

生成的参数类长这样:

csharp 复制代码
internal sealed class CalendarCreateEventArgs
{
    internal const string ToolName = """calendar.create_event""";

    public string CalendarId { get; }
    public string Summary { get; }
    public string Start { get; }
    public string End { get; }
    public string? IdempotencyKey { get; }
    public bool? DryRun { get; }

    public static CalendarCreateEventArgs Unpack(IReadOnlyDictionary<string, object?> args)
    {
        var calendarId = ToolArgs.RequireString(args, """calendar_id""");
        var summary = ToolArgs.RequireString(args, """summary""");
        var start = ToolArgs.RequireString(args, """start""");
        var end = ToolArgs.RequireString(args, """end""");
        var idempotencyKey = ToolArgs.OptionalString(args, """idempotency_key""");
        var dryRun = ToolArgs.OptionalBool(args, """dry_run""");
        return new CalendarCreateEventArgs(calendarId, summary, start, end, idempotencyKey, dryRun);
    }
}

执行器只做三件事:取参、调 SDK、挑要返回的字段。

csharp 复制代码
[ToolHandler(typeof(IFeishuTenantCalendarCreateEventTool))]
public async Task<ToolResult> CreateEventAsync(
    IReadOnlyDictionary<string, object?> arguments, CancellationToken ct)
{
    var executor = new ToolExecutor(FeishuToolNames.CalendarCreateEvent, _maxResultLength);
    return await executor.RunAsync(async () =>
    {
        var args = CalendarCreateEventArgs.Unpack(arguments);
        var outcome = FeishuApiResultReader.Read(await _calendarEventClient.CreateCalendarEventAsync(..., ct));
        return executor.FromApiUntruncated(outcome, data => new JsonObject { ["event_id"] = data.Event?.EventId });
    });
}

这些产物都来自同一次语法树扫描,所以"Schema 有 dry_run、Args 没有"这类错在结构上不会发生。真要说一致性靠什么保证,靠的是同源,不是靠人记得同步。

5. 变更门禁

5.1 快照文件

FeishuToolSchemas.golden.txt 一共 351 行,每行是 工具名 \t 完整信封 JSON。单个信封展开后大致是这样:

jsonc 复制代码
{
  "name": "calendar.add_event_attendees",
  "description": "向指定日程添加与会者...写操作:需 calendar:calendar。",
  "parameters": {
    "type": "object",
    "properties": {
      "calendar_id": { "type": "string", "description": "..." },
      "event_id":    { "type": "string", "description": "..." },
      "attendee_ids":{ "type": "array", "items": { "type": "string" }, "description": "..." }
    },
    "required": ["calendar_id", "event_id", "attendee_ids"]
  },
  "x-feishu": {
    "risk": "write",
    "is_write": true,
    "identity": "tenant",
    "required_scopes": ["calendar:calendar"],
    "source": {
      "sdk": "IFeishuTenantV4CalendarEvent.CreateCalendarEventAttendeeAsync",
      "http": "POST",
      "route": "/open-apis/calendar/v4/calendars/{calendar_id}/events/{event_id}/attendees"
    },
    "affordance": { "when": ["..."], "avoid_when": ["..."], "prerequisites": ["..."], "tips": ["..."] },
    "output_schema": { "...": "..." }
  }
}

x-feishu 是厂商扩展块,不放进参数 Schema 的关键字空间。MEAI 要求 JsonSchema 是纯参数 Schema,风险、身份、scope、SDK 锚点这些东西没有合适的位置,只能单独放一块。

5.2 改完之后会发生什么

用快照而不是断言几个字段,是因为断言只能防你想得到的变化。快照防的是字节级变化,并且把变化变成一行可以直接读的 diff。评审时看的是"模型那边的世界要不要这样变",而不是"代码有没有改"。

5.3 诊断码

诊断 级别 拦什么
MUDFT014 Error golden 漂移
MUDFT016 Error 工具身份与承载接口名推导结果不一致
MUDFT019 Error Source 锚不到真实 SDK 符号
MUDFT026 Error 生成器内部异常的兜底诊断
MUDFT028 Error affordance 结构不合法(四节缺失或出现一级标题)
MUDFT029 Error affordance 文件名对不上工具名(目录里放 README 也算)
MUDFT030 Error affordance 里反引号引用的 域.工具 不存在
MUDFT031 Warning affordance 超预算(1024 字符 / 单节 320 字符)

5.4 64 个契约测试

类别 代表 锁什么
规模与计数 ToolSurfaceScaleContractGuards、ToolCountDocContractGuards 工具数、域数、读写分布,以及文档里的数字
契约一致性 FeishuToolContractGuards、FeishuToolDescriptorContractGuards Schema 键 = Names = Contracts = 注册表 = 对照表
剖面 FeishuToolProfileContractGuards 逐槽反射锁值
资产 AffordanceContractGuards、AffordanceCoverageContractGuards、GuidanceAssetContractGuards 结构、覆盖率、引用存在性
导出 SkillsExportContractTests、SkillsVersionConsistencyContractGuards 产物逐字节一致、package_version 与包版本一致
接线 DomainRegistrarTests、ToolDomainCoresWiringContractTests 全域入口与逐域入口的工具集等价
执行器骨架 ToolExecutorSkeletonGuards 不许手写 if (!outcome.Ok) / catch 那套机械代码
引擎单一性 SingleToolSurfaceEngineContractGuards 仓库里不能有第二个 [Generator],不能双引用引擎

5.5 文档里的数字不能手抄

工具面从 365 收敛到 351 时,合并了 14 个能力重叠的工具,比如 mail.send_message 和 mail.send_draft 合成 mail.send。一次改动要同步的地方包括 golden 快照、全部 skills 产物、《工具权限对照表》v1.19 的 351 行,以及三份 README 里的规模数字。

这种事靠人记迟早会漏。现在数字由 ToolSurfaceScaleContractGuards 和 golden 快照机械比对,对不上就报红。

也正因为如此,文档里的历史数字和当前产物可能同时存在,比如某处还写着 1279,而编译期目录已经是 1228。这不妨碍阅读,但说明文档里的数字只能当说明,不能当断言依据。

5.6 快照重固化的一个坑

快照内容由测试从已经编译好的程序集里读。dotnet test 只在引用项目被判过期时才重建。新增一份 Affordance/*.md 经常不触发重建,于是生成器看不到新资产,固化程序把原样内容写回去,测试全绿,覆盖率却没动。

正确顺序是三步,第一步不能省:

powershell 复制代码
# 1. 强制重跑生成器(-p:FeishuToolRefreeze=true 让构建期不比对快照)
dotnet build Mud.Feishu.AI.Tools/Mud.Feishu.AI.Tools.csproj -c Debug --no-incremental -p:FeishuToolRefreeze=true

# 2. 让测试用运行时真实 Schema 重写快照
$env:FeishuToolGoldenUpdate='true'
dotnet test Tests/Mud.Feishu.AI.Tools.Tests -p:FeishuToolRefreeze=true `
  --filter "FullyQualifiedName~FeishuToolGoldenTests"

# 3. 评审 diff → 重导出 skills → 更新权限对照表 → 记 CHANGELOG

6. 运行期装配:注册不等于启用

6.1 三段式注册

csharp 复制代码
public static IServiceCollection AddFeishuTools(this IServiceCollection services, Action<FeishuToolRegistry>? configure = null)
    => AddFeishuWriteToolCores(
           AddFeishuReadonlyToolCores(
               AddFeishuToolInfrastructure(services, configure)));

域入口如 AddFeishuCloudDocTools、AddFeishuImTools 是"域组"粒度。宿主只要其中一部分工具时,用白名单裁剪,不需要再把入口拆回一域一个。

6.2 两条链的同步问题

每个 AddFeishu{Tools}Core() 在全域入口和逐域入口都要登记一次。少登记一处,两条装配路径的工具集就不等价,DomainRegistrarTests 会直接把差异点出来。

单执行器混合域(Board、Attendance、Markdown、BitableDeep、SheetsDeep、TaskDeep、MailDeep)只能登记在一条链上。两条链都登记,域注册器就会被装配两次,FeishuToolRegistry 抛"工具已注册"。这个错在客户端缺席时看不见,宿主把客户端补齐之后整域一起炸。

读写混合域(mail、contact-department)的读工具跟着写链走。

6.3 注册表语义

操作 语义
Register(definition) 收进注册表,不启用;重名抛异常
MapTool(name) 白名单启用
IsEnabled(name) 是否进入模型可见面
EnabledTools 当前启用集,模型面、guidance、MCP 共用
AllTools 全部已登记工具,守卫比对用
jsonc 复制代码
{
  "FeishuAgent": {
    "Tools": ["bitable.list_tables", "contact.search_user"],   // 只读白名单
    "WriteAllowList": ["im.send_message"],                      // 写工具单独键控,默认空
    "MaxToolRisk": "high-risk-write",
    "AllowedIdentities": ["tenant", "user"],
    "ContentSafetyMode": "warn"
  }
}

名单放错类别(写工具写进 Tools)在注册期就失败。启用了 identity=user 的工具却没放行 user,装配期直接报错并列出工具名,不会等到每次请求再拒绝一次,让人误以为是权限问题。

6.4 装饰顺序

csharp 复制代码
var toolBridge   = new FeishuToolAIFunction(definition, schemaJson, accessor);
var budgeted     = new ToolBudgetAIFunction(toolBridge);
tools.Add(ApplyApprovalGate(budgeted, definition));   // IsWrite ? ApprovalRequiredAIFunction : 原样

审批标记必须放在最外层。MAF 的 FunctionInvokingChatClient 用 function is ApprovalRequiredAIFunction 判断"这次要不要先走人工审批"。如果把预算装饰器套在它外面,标记对 MAF 不可见,写工具的审批会静默失效,模型可以直接执行写操作。代价是预算耗尽时用户可能先被问一次审批,这个代价可以接受。

6.5 Schema 和描述怎么取出

csharp 复制代码
_cachedParameterSchema = ToolSchemaJson.ExtractParameters(schemaJson);          // 构造期解析一次
_description = _definition.Description + ToolAffordanceText.ForDescription(schemaJson);

信封是 OpenAI Functions 的形状,不是合法的 JSON Schema。ToolSchemaJson 是全包唯一做"信封转参数 Schema"的地方。直接拿信封当 Schema,参数约束会全部丢失,等于告诉模型"任意 JSON 都行"。

affordance 拼到 Description 里,是因为 x-feishu 不属于 Schema 关键字,模型读不到。没有资产时返回空串,描述保持逐字节不变。

7. 执行链

①②③ 的次序最容易被改坏,摘出来看一下:

csharp 复制代码
// ① 上下文校验
if (string.IsNullOrWhiteSpace(context.AppKey))
    return EgressResult(tool.Name, "工具执行上下文缺少 appKey------多租户隔离禁止默认应用兜底");

// ①' 入站净化:先于授权门禁(授权器可能把参数写审计,审计必须看到已净化形态)
var argumentFailure = ToolArgumentSanitizer.Validate(arguments);
if (argumentFailure is not null)
    return await DenyAsync(..., ToolErrorSubtype.InvalidArgs, $"invalid_args: {argumentFailure}", ...);

// ② 策略轴
var (policyFailure, policySubtype) = EvaluatePolicy(tool);
if (policyFailure is not null)
    return await DenyAsync(..., ToolErrorCategory.Policy, policySubtype, $"policy_denied: {policyFailure}", ...);

// ③ 授权门禁:拒绝时零调用下游、不切换租户上下文
var gate = await AuthorizeGateAsync(tool, arguments, context, cancellationToken);
if (!gate.Allowed)
    return await DenyAsync(..., gate.Category, gate.Subtype, gate.Reason, ...);

7.1 顺序为什么不能换

换法 后果
入站净化挪到授权之后 审计里的参数可能是未净化的形态(A5 不变量)
租户切换挪到授权之前 被拒绝的调用也会切上下文,多一次令牌查找,也多一处泄漏面
出站净化挪到整形之后 宿主的整形钩子可能把敏感内容拼回结果(A4 不变量)
内容安全挪到净化之后 脱敏后的文本参与规则匹配,命中率和审计口径都会偏

这些性质都用运行时行为断言锁,不扫源码。像"必须经过某一关"这种要求,扫源码只能证明有人写过,证明不了每次调用都走。

7.2 错误返回结构化结果

模型读到结构化错误,可以自己换参数或降级;抛异常会把整轮对话打断。

NormalizeUnstructuredFailure 还会把执行器返回的 [tool_error] 纯文本升级成结构化错误,免得旧执行器和新错误契约长期两种形态并存。

拒绝路径统一走 DenyAsync,同时投递审计,并带一个机器可读的 reason_code。

7.3 幂等与分页

有 idempotency_key 参数的工具,会把键透传成平台的 client_token;dry_run=true 时不下发请求,也不消耗键。

fetch_all=true 时交给 ToolPagination.AggregateOutcomesAsync 翻页聚合。让模型自己写循环不现实,每一页都是一次独立的下游调用,失败语义得统一。

28 个常用写工具里,10 个暴露了幂等键,18 个没暴露,其中 8 个天然幂等、10 个非幂等。这张表实际上就是"模型能不能安全重试"的说明书。

8. 导出:同一份契约的几种形态

8.1 四种出口

出口 形态 谁在用 与白名单的关系
进程内 Agent IReadOnlyList<AIFunction> MAF / MEAI 只含已启用工具
MCP tools/list JSON-RPC 信封 进程外 Agent 只含已启用工具(名字 . 换成 _)
MCP Envelope 导出 静态全量清单 CI、桥接器、差异比对 不受白名单影响,表达"能力存在"
Skills 导出 skills/feishu-{domain}/SKILL.md + references 仓外 Agent 全量能力面

四种形态,数据源只有一份,都是编译期常量。

8.2 Guidance 的全文和摘要

摘要不再单独写一份资产,而是从同一份 L1 全文里取两个稳定片段:

text 复制代码
【{domain}】{H1 标题}|{「路由优先级」首条} → 全文与深层避坑见 feishu.guidance_read(域, 主题)

单独写摘要,改全文时就会忘。进程内 Agent 和 MCP 两条链也要用同一种摘要,所以 GuidanceRoutingSummary 做成公开实现,两条链共用,各自的开关只决定用摘要还是全文。

8.3 Affordance

text 复制代码
Mud.Feishu.AI.Tools/Affordance/{契约工具名}.md
  ## WHEN
  ## Avoid when
  ## Prerequisites
  ## Tips

四节都必须存在且非空,否则 MUDFT028。文件名要对上工具名,否则 MUDFT029。正文里反引号引用的 域.工具 会机械校验存在性,否则 MUDFT030。单份资产不超过 1024 字符,单节不超过 320 字符,超了是 MUDFT031,走基线拦增量,处理方式是压文案,不是抬预算。

覆盖率按风险分级。写面 100%,高危和写两级都要有资产。读面从 18% 起步,只覆盖高频的 list/get。当前合计 207 份,100,204 个字符。

读面没做满,是因为低频单点读取的选型问题更适合放在域 guidance 里讲,全部补资产只会把描述预算吃光。

8.4 Skills

csharp 复制代码
IToolSchemaExporter.Export(ToolSchemaDialect.Skills);
// => {"dialect":"skills","files":[{"path":"skills/feishu-im/SKILL.md","content":"..."}, ...]}

导出是纯函数,不做文件 IO。数据源全是编译期常量,落盘交给宿主、脚本或测试,这样库本身不用碰磁盘。

域、工具、参考文档都按 Ordinal 排序,同一份输入必然得到逐字节相同的产物,否则这类快照测试会随机变红。

SKILL.md 正文原样嵌入 L1 guidance,不再摘要或改写。加工一次就多一份来源。

产物里有两个版本号:version 是产物形态版本,结构变了才动;package_version 是包版本。宿主拿到的是一个目录树,看不到包元数据,没有 package_version 就不知道手上这份文件是不是当前包的。

8.5 描述长度

有一次工具面收敛顺手做了两件事:让相邻工具的首句互相指路(bitable 工作流、docx 三种粒度的读),删掉 174 处重复的写权限模板句。

描述总量从 54,891 字符降到 47,495。

判断标准是:执行链能强制的事实,不要写进描述让模型自觉遵守。描述只回答"什么时候用这个、不用那个、前置是什么"。

9. 没有策展的 877 个方法怎么办

层 内容 模型可见
L1 能力目录 编译期聚合事实(方法总数、分组分布、策展计数) 否,internal
L2 策展工具 351 个 [AgentTool] 白名单启用后可见
L3 feishu.capability_lookup 判断一个能力是不存在,还是存在但没策展 是
L3 feishu.schema_read 查任意 SDK 方法的 HTTP、路由、参数、令牌、风险、策展状态 是,只读
L3 feishu.guidance_read 按 {域}/{主题} 读 references 是,只读
L3 feishu.tool_search 在已策展工具里按语义检索 是,只读

9.1 为什么删掉 api_call

它曾经是一条"方法名加参数字典,动态调度 HTTP"的兜底通道。删掉有三个原因。

一是同一能力出现两条调用路径。策展工具和动态调度的投影、幂等、风险语义都不一样,维护时要在两处对齐。

二是风险最高。任意已登记方法都能被调度,相当于把整个 SDK 变成可调用面。

三是没人用。Demos 没启用,只有测试覆盖。

删掉之后,未策展能力对模型的语义从"可以兜底"变成"如实告知"。GuidanceToolReferenceContractGuards 会检查 guidance 里引用的 feishu.* 是否真实存在,防止同类东西再长回来。

9.2 候选名只用来提建议

CapabilityApplyLoop 把未策展方法按动词表、分页后缀、ById 尾巴投影成建议工具名,供人判断要不要策展。

它只产出建议,不产出通道。这条写在代码注释里,也是评审时会看的一条。

10. 几条工程上的做法

10.1 防止假绿

规则 机制 不这么做会怎样
标准构建必须 --no-incremental 增量构建会跳过源生成器,诊断数恒为 0 "0 诊断"是假的,产物其实没生成
AOT 严格模式逐项目构建,同时断言 0 错误和 0 诊断 构建失败时诊断数也是 0 计数断言在构建失败时照样通过
测试断言 total > 0 死掉的 testhost 会写出空 TRX,退出码还是 1 "测试通过"其实一条都没跑

三条都来自同一个教训:只断言计数、不校验前置状态,等于给自己发假证书。

10.2 一份数据只有一个出处

同一件事出现第二份写法时,先想想能不能删掉。这个仓库里多数时候答案是可以。

10.3 什么该报错,什么该跳过

场景 处理 原因
可选包未安装(Mud.AI) 跳过,该域工具不注册 宿主有权不装
域客户端未注册 跳过,注册器不进注册表 白名单映射期才报未注册,错误面收敛
AppKey 缺失 报错 多租户隔离不允许默认应用兜底
golden 漂移 报错,中断构建 模型可见面变更必须评审
Source 锚不到 报错 失效工具不能发布

标准很朴素:缺了它,运行时会不会出错,或者悄悄出错。会,就硬失败;不会,就跳过。

10.4 AOT

工具面几乎没有反射。Schema、契约、参数名都是 const string;JSON 序列化走 FeishuJsonAot 和源生成上下文;唯一一次 JsonDocument.Parse 发生在构造期,用来缓存参数 Schema 和 affordance,不在每次调用里做。

这是设计取向带来的顺带结果:能提前算出来的,就别留到运行时算。

10.5 契约测试怎么写

一个闭环只留一条等式。Schema 键 = Names.All = Contracts = 注册表 = 对照表 用一条断言表达,不拆成五条计数。

新守卫要能被触发。配不上反例的守卫只是装饰。

测试里 File.ReadAllText 读到的路径要在 FeishuDocs 登记。文档一搬家,没登记的守卫就会结构性变红。

"必须经过"这类性质用运行时行为断言,不扫源码。

11. 新增一个工具

11.1 六步

前四步是人做的,后两步是跑命令。真正容易出问题的是第四步:写执行器的时候顺手在别的地方也注册一遍,或者只改了全域入口。

11.2 完成标准

# 判据
1 golden diff 已评审
2 新 scope 回填权威清单,权限对照表同批更新
3 有真实调用链路用例,断言 method、path、body、query 实参,而不只是"工具存在"
4 有一条参数非法的结构化错误负例
5 Source 能解析,MUDFT019 通过
6 写工具默认不启用,且过授权门禁
7 risk / is_write / identity 与 Schema 一致
8 写类工具补 affordance 资产
9 skills 产物逐字节一致,package_version 匹配

11.3 四个常见的坑

坑 现象 防线
Source 写成单段 nameof 丢了接口名,工具锚不上 SDK MUDFT019
接口名缺令牌标记 身份落到默认 tenant,user 工具被当租户工具 MUDFT016
单执行器混合域在两条链都登记 报"工具已注册",客户端补齐后才炸 DomainRegistrarTests
Affordance/ 目录里放 README 整份目录被当资产,报文件名不匹配 MUDFT029

12. 收尾

这套生成流程本身不复杂,真正花时间的是外围:快照、诊断、64 个契约测试,还有那些"构建通过了但结果是错的"的边角。生成器大概一周能写完,把"模型看到的东西变了必须有人知道"这件事做扎实,要长得多。

如果要从这里带走一点东西,大概是:模型能看到的那部分系统,值得当成一份需要评审的产物来对待,而不是运行时随手拼出来的字符串。

相关推荐
xifangge20251 小时前
把 DeepSeek 搬回自己电脑:Ollama+OpenWebUI+AnythingLLM 本地私有部署(含离线一键包)
人工智能·deepseek
kyle~1 小时前
DINOv3 --- 自监督通用视觉特征引擎
人工智能·计算机视觉·ai
天远API1 小时前
零信任架构实战:基于天远企业二要素验证构建自动化跨境物流货代授信签约网关
人工智能·1024程序员节·工具分享
@嵌入式扫地僧1 小时前
实战教程:NVIDIA Jetson Nano 8GB 搭建本地 RAG 大语言模型知识库完整指南
人工智能·ai·jetson nano·1024程序员节·rag
全栈探索者1 小时前
出图模型怎么选?同一只猫的四场实测:Z-Image、Qwen-Image 2.1、SD1.5 三维对决
人工智能·macos·aigc·stablediffusion·comfyui
小年糕是糕手1 小时前
【AI】用 Python 做一个 AI 截图检索助手:把收藏夹里的截图找回来
linux·人工智能·python·chatgpt·agent·codex·deepseek
特立独行的猫a1 小时前
用仓颉写桌面应用的“薄壳框架”cj-tauri ,以及它的 AI 对话客户端 ai-chat
人工智能·harmonyos·tauri·仓颉·鸿蒙pc·cj-tarui·ai客户端
云中飞鸿1 小时前
.Net和C#有什么关系?(从C++视角观察)
开发语言·c#·.net
乐动心情1 小时前
AI推理性能优化的关键技术即演进路径
人工智能·性能优化