为什么想做一个能干活的东西
飞书上的 AI demo,大多长一个样:收到消息、拼一段 prompt、调一次聊天接口、把结果发回去。演示的时候很顺,可一旦你想让它真的替人做事------查某个人下周有没有空、把约好的会挪个时间、把纪要整理成文档------这套结构立刻不够用。它记不住上一轮说过什么,也调不动飞书任何一个接口,模型能给的只有一句「请手动操作」。
所以「能干活」是条分界线。跨过去之后你会发现,缺的其实不是模型,是一整套运行时:会话怎么存、工具怎么调度、调用链怎么观测、出错了怎么查、写操作怎么拦。这些事单拎出来都不难,凑在一起就是一堆容易写错的胶水代码。
我们的选择是把通用部分交给 Microsoft Agent Framework(下面统一叫 MAF),飞书特有的部分留在 SDK 里。这篇就把这套东西拆开讲:架构怎么分层、工具怎么管住、会话和流式怎么接,以及几个到现在想起来还后背发凉的坑。目标倒不复杂------让机器人在飞书里像个能派活的同事,而不是只会应答的客服。
它应该比 demo 多些什么
口说无凭,先列表。
| 能力维度 | 套壳机器人 | 工作搭子 |
|---|---|---|
| 对话记忆 | 每轮失忆 | 会话持久化,隔天还记得上周的约定 |
| 干活 | 「抱歉,我无法操作日历」 | 351 个策展工具,读写分治 |
| 写操作安全 | 无 | 白名单 + 风险分级 + 人工审批,三道闸 |
| 多租户 | 无概念 | 令牌隔离,宁可失败也不跨租户兜底 |
| 排查手段 | 全靠猜 | OTel 全链路,每个回合有 Span |
| 流式回复 | 整段蹦出来 | 卡片打字机,降级自动切分片编辑 |
右边这一列没有一行是「调一次聊天接口」能解决的。这也是为什么我们最终把主循环交给 MAF------这些能力里的通用部分(Agent 循环、Function Call、状态袋、审批配对)框架已经写好了,剩下的飞书特有部分才是 SDK 该干的活。
飞书官方有 CLI 了,为什么还自己写
写这篇之前,我把 larksuite/cli 又拉下来玩了一遍。2026 年 3 月底开源的,Go 写的、npm 分发,npx 一条命令装完,200+ 精选命令、2500+ Raw API 全量兜底、26 个 Agent Skills 开箱即用,OAuth 登录还能用你个人的飞书身份。说实话做得挺好,好到我有一瞬间怀疑这活是不是白干了。
后来想明白了:它俩不是一回事,解决的是两种场景。
CLI 是「终端里的 Agent 去操作飞书」。人在 Claude Code、Cursor 里说一句话,Agent 拼一条命令 spawn 出去,跑完把 JSON 拿回来。身份是 OAuth 登录的 user 或 bot,凭证放系统钥匙串,形态天然贴着个人工作流。三分钟装完就能干活,这点确实服气。
我们要的是「飞书里的 Agent,跑在服务端、嵌进自己的产品」。一个进程托管多个企业应用,用户消息从 WebSocket 进来,Agent 在进程内跑一轮、中间调十次工具、最后流式回复回去,每一步都要过执行链、要审计、要能挂审批。
拐点落在三个地方,而且每一条都是 CLI 的形态决定的,不是它做得不好:
进程边界。 CLI 每次调用都是 spawn 一个进程、跑一条命令、退出。单次工具调用几十毫秒开销,看着不算什么,但一个回合里工具调用是连串的(查忙闲 → 查日程 → 改日程),还要复用 HTTP 连接池、令牌缓存、租户作用域,这些东西全在进程边界外面,拿不回来。服务端每轮对话反复起进程,吞吐和延迟都不划算。
身份模型。 CLI 的卖点是 OAuth 交互登录,人坐在终端前点一下授权。服务端多租户场景里没有「人在终端前」这回事,凭证必须来自应用配置(App ID / Secret),令牌由 SDK 托管、按租户隔离。让每个租户挂一个交互式登录会话,运维能疯。
治理深度。 CLI 有输入注入防护、输出净化、钥匙串存储,这些都很对。但它没有「写工具默认全关、逐个列名启用、高危动作必须人工点头、审批响应配对校验」这一层。企业里让 Agent 改日程、批审批,这一层不是可选项。
再加两条小的:工具契约在 CLI 那边是 JSON 文本,模型看到的就是字符串;我们这边每个工具都是编译期 Schema、source 三元组跟 SDK 交叉校验、AOT 零反射。可观测性同理------进程外调用进不了同一个 Trace,工具调用没有 Span 归属,出问题只能翻日志。
所以结论不是「CLI 不行」,而是分工不同。下面这张图里左边那条分支,我会毫不犹豫让你直接用 CLI:
flowchart LR Q{"Agent 跑在哪里?"} Q -->|终端 / Claude Code / Cursor| A"飞书官方 CLI\
OAuth 双身份 · 200+ 精选命令" Q -->|服务端 / 多租户 / 自有产品| B"本 SDK 进程内集成\
七步执行链 · HITL 审批 · 编译期 Schema" A --> A2"npx 装完即用\
2500+ Raw API 兜底" B --> B2"同一 Trace\
写工具默认全关"
逐条摊开是这么个对照:
| 维度 | 飞书官方 CLI | 本 SDK(进程内) |
|---|---|---|
| 形态 | 终端命令,每次调用一个进程 | 进程内库,随宿主应用走 |
| 身份 | OAuth 交互登录,user / bot 双身份 | App ID + Secret,令牌托管、多租户隔离 |
| 覆盖 | 200+ 精选命令,2500+ Raw API 兜底 | 351 个策展工具 + 1268 个强类型方法 |
| 治理 | 注入防护、输出净化、钥匙串 | 七步执行链 + 写工具白名单 + HITL 审批 |
| 契约 | JSON 文本输出 | 编译期 Schema,source 三元组交叉校验 |
| 观测 | 进程外,独立日志 | 同一 Trace,工具调用有 Span |
| 适合 | 个人终端、Claude Code / Cursor 等现成 Agent | 服务端、多租户、嵌进自有产品 |
说白了,如果你的团队就在 Claude Code 里干活,官方 CLI 甚至比接 MCP 更省事,别为了「统一技术栈」绕远路。而只要 Agent 得跑在自己服务里、要对每个租户负责,那 CLI 这层壳补不上我们要的那几块,得自己长在进程里。
分层:通用的交给 MAF,产品形态留在 SDK
先看整体。核心是一个三层委托链:
flowchart TB A"FeishuAgent(产品层)\
会话解析 · 选项装配 · 飞书遥测" B"ObservedDelegatingAgent(运行期编织)\
摘要先于 Run · Span · 耗时 · token 用量" C"ChatClientAgent(MAF 内层)\
只做一件事:模型 → 生成" D"IChatClient(键控服务)" E"任意 OpenAI-compatible 端点\
OpenAI / 智谱 / 私有化 vLLM" A -->|委托| B B -->|委托| C C --> D --> E C -.->|Id / Name / GetService / 会话钩子<br/>全部由 MAF 基类提供,产品层不手写| N"MAF 基类"
这个分法背后的立场很简单:MAF 管「任意 Agent 都要面对的事」,飞书 SDK 管「飞书这个产品特有的事」。哪些工具、什么权限、时间用什么格式、消息走哪条通道------这些产品事实一行都不进 MAF 的层。好处是实打实的:MAF 升版本的时候,产品层不用跟着地震。
有个细节单独说,因为它是那种「编译不报错、跑起来也没异常、只有监控里少了一截 Span」的隐形坑:FeishuAgent 手下一个 Id、Name、GetService 都没写,全部继承自 MAF 的 DelegatingAIAgent。不是偷懒。GetService 的正确语义是「先判自身、再委托内层」------要是自己手写时漏了先判自身,宿主拿 AIAgent 引用调下去会直接命中内层的 ChatClientAgent,你在外面织的遥测和摘要瞬间形同虚设。这种坑踩进去半天都爬不出来,让基类管这摊事,是省心也是自保。
装配:五行代码
空讲架构没意思,直接看代码:
csharp
builder.Services.AddFeishuApp(builder.Configuration); // ① 飞书应用 + 令牌托管
builder.Services.CreateFeishuServicesBuilder() // ② 全量 API 接口
.AddAllApis().Build();
builder.Services.AddFeishuTools(); // ③ 工具注册表(注意:注册 ≠ 启用)
builder.Services.AddFeishuOpenAIChatClient( // ④ 模型客户端
"main", "glm-4-flash", apiKey,
"https://open.bigmodel.cn/api/paas/v4");
builder.Services.AddFeishuAgent(builder.Configuration) // ⑤ Agent 本体 + 集成面
.AddFeishuStreamingChannel()
.AddFeishuImConversationHandler();
五行各自的分工和几个容易被忽略的点,一张表说清:
| 步骤 | 干什么 | 值得多看一眼的地方 |
|---|---|---|
① AddFeishuApp |
应用注册 + 令牌全生命周期 | 获取/缓存/刷新/失效级联全自动,Agent 层从头到尾不碰 token |
② AddAllApis() |
挂全量 API 接口 | 工具的下游就是这些接口 |
③ AddFeishuTools() |
351 个策展工具进容器 | 「注册」不等于「启用」,给模型哪些由配置决定 |
④ AddFeishuOpenAIChatClient |
键控模型客户端 | 端点强制 HTTPS(环回例外);解析不到装配期就炸,不透传 null |
⑤ AddFeishuAgent |
Agent + 会话 + 通道 | 配置启动期校验,ValidateOnStart,配错拒启 |
第 ④ 步的 HTTPS 白名单是我特别喜欢的一个细节。我见过不止一个项目把模型端点配成 http 内网地址,用户对话明文飞出去。这种安全默认就该焊死在 SDK 层,不给配置留口子。
配置长这样。注意 WriteAllowList,写工具默认一个都不给:
json
{
"FeishuApps": [{
"AppKey": "default", "AppId": "cli_xxx",
"AppSecret": "xxx", "IsDefault": true
}],
"FeishuAgent": {
"Tools": ["bitable.list_tables", "contact.search_user", "docx.get_document"],
"WriteAllowList": ["calendar.update_event"],
"MaxToolRisk": "high-risk-write"
}
}
然后业务代码里用它,就没什么可说的了------注入、调用,完事:
csharp
public class ChatController(IFeishuAgent agent) : ControllerBase
{
[HttpPost("chat")]
public async Task<IActionResult> Chat([FromBody] string userMessage, CancellationToken ct)
{
var response = await agent.RunAsync(userMessage, ct);
return Ok(response.Text);
}
}
工具:351 个,默认只开只读
工具面是这套东西里最花心思的地方。351 个工具不是「把 REST 接口包一层」那么粗暴------每个都经过程度不低的策展。拿「改日程」背后的工具 declaration 看看:
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", Required = true)] string calendar_id,
[ToolParameter("summary", "日程标题", Required = true)] string summary,
[ToolParameter("start", "开始时间(RFC3339,如 2026-10-01T14:00:00+08:00)", Required = true)] string start,
[ToolParameter("end", "结束时间(RFC3339,须晚于 start)", Required = true)] string end,
// ......description / timezone / idempotency_key / dry_run 略
);
}
这个声明里有几处值得说。Schema 是源生成器从这些 attribute 编译期生成的,零反射,AOT 友好;Source 三元组把工具锚回 SDK 方法,跟真实接口交叉校验,对不上直接构建失败------工具面和 SDK 悄悄错位这种事,在编译期拦下来最便宜。
权限由三个配置项控制:
| 闸门 | 管什么 | 默认 | 拦截时机 |
|---|---|---|---|
Tools 白名单 |
只读工具启用面 | 空(一个不启用) | 注册期 fail-fast:名单里出现未注册的名字直接拒启 |
WriteAllowList |
写工具启用面 | 空(一个写工具都不开) | 同上,且与只读名单读写分离、互斥校验 |
MaxToolRisk |
风险上限三档 | high-risk-write |
执行链最前端,超额工具零调用下游、不切租户 |
刚接上的 Agent「能读、不能写」------不是安全洁癖,是写接口出的事不可逆。等真要开写权限了,还有一道强制授权门禁(EnforceToolAuthorization 默认开)拦着:写操作必须过宿主实现的审批通道,模型自己拼一条「我批准了」塞回上下文是没用的,MAF 的 ApprovalResponseBindingChatClient 会做配对校验,配不上的一律不认。写工具参数里还留了 dry_run,模型不确定的时候可以先预演一遍要看下发的请求体------比直接干下去再回滚便宜得多。
再补一个只有踩过才会注意的细节:工具的时间参数一律 RFC3339。飞书内部并不统一------日历 v4 收 RFC3339,任务 v2 的 timestamp 却是毫秒字符串。要是两种格式都暴露给模型,它迟早毫秒当秒用,产出不报错但错得离谱的日程。所以格式转换被焊死在工具层,模型只感知一种格式,参数描述里再写死一遍,双保险。
工具结果回填也有门:先做白名单投影(不该进模型上下文的字段从源头剥掉),再按 MaxToolResultLength 截断------不然拉个两千行的多维表格,上下文窗口当场告爆炸。
会话历史:一个和直觉相反的语义
会话历史放 MAF 的状态袋里,键叫 feishu.agent.history,裁剪窗默认 50 条。存取走 IConversationStore,默认内存实现,分布式部署换 Redis 注册就行------事件入口一行不用改。
这部分本来没什么可写的,直到我们撞上那个坑。我一直以为历史裁剪是「请求视图」:超出窗口的旧消息这轮不发给模型而已,存储里还在。画个图对比一下我以为的和实际的:
flowchart TB subgraph view"以为的裁剪:只是本次请求的视图" V1"存储里 m1...m80 全在" -.->|本轮只发送| V2"m73...m80" end subgraph real"实际的裁剪:结果写回存储" R1"m1...m72 已被删除" -->|不可逆| R2"只剩 m73...m80" R1 -->|整条丢弃| R3"含 FunctionCallContent 的消息" end view -->|MessageCountingChatReducer 写回状态| real
对,裁剪结果会写回会话状态,等于落库删除 ;更要命的是,带 FunctionCallContent 的消息------工具调用轨迹------直接整条丢掉。当时的现象是「模型忘了自己刚调过什么工具」,我们排查了一整圈不相干的地方,最后发现语义如此。如果你需要保留工具轨迹,得换一个不裁剪的 reducer 实现。
顺带一句:摘要器的保留窗是从裁剪窗派生的(MaxHistoryMessages / 2)------这两个旋母其实是一根轴,调小裁剪窗会连带收紧摘要。
回复:消息进来之后怎么发出去
前面讲的都是主动调用。AddFeishuImConversationHandler() 挂上之后,用户在飞书里 @机器人,一条消息进来,Agent 跑一轮,回复流式推回去。整条链路是这样:
flowchart TB M"飞书 IM:@消息" --> W"WebSocket / Webhook 入口\
签名校验 · 去重 · 解密" W --> H"ImMessageConversationalEventHandler\
上下文装配:群历史 / 媒体转写 / 知识检索" H --> A"FeishuAgent.RunAsync\
流式产出" A --> D{"CardKit Begin 成功?"} D -->|是| K"CardKit 流式通道\
卡片打字机效果" D -->|否,自动降级| E"EditMessageChannel\
分片编辑:200 字符一次,避开频率限制"
降级链是这段的题眼:首选 CardKit 会话内流式(体验最好),起不来就自动退到分片编辑(稳但朴素)。事件处理器对这条链零感知------挂了就降级,业务代码不掺和。这两条通道直接决定了它说话像不像人:没有人愿意盯着一颗「对方正在输入...」等三十秒再看一大段蹦出来。
观测:出问题的时候查什么
遥测走一个中性端口 IAgentTelemetry,飞书侧实现打 feishu.agent.* 的 Span 和指标。接上 OTLP,Jaeger 里整条链路是通的:
gantt title 一次回合的 Span 瀑布(相对时间) dateFormat x axisFormat %s section 回合 feishu.agent.turn :a1, 0, 95 section 子 Span chat.completion(模型) :a2, 4, 55 tool.calendar.list_events :a3, 58, 71 tool.calendar.update_event :a4, 73, 86 reply.stream(流式出站) :a5, 88, 95
没有这条链路,「为什么它没调工具」基本只能靠猜;有了它,至少能猜得有依据。
几个踩过的坑
几周攒下来的坑,按影响从大到小排:
| 坑 | 现象 | 病因 | 解法 |
|---|---|---|---|
| 历史裁剪是落库删除 | 模型忘了刚调过的工具 | reducer 把裁剪结果写回状态,工具轨迹整条丢 | 需要轨迹就换不裁剪的 reducer |
GetService 没先判自身 |
监控里少一段 Span,无报错 | 委托直接命中内层 ChatClientAgent,外层织入被绕过 |
别手写,用基类的委托语义 |
| 状态袋想存自定义类型 | AOT 下序列化炸 | 自定义类型要备 JsonTypeInfo |
状态袋只存 string(如 pending_reply) |
| keyed 客户端配错 | 运行期首调才 null 炸 | 解析失败被透传 | 解析即校验,装配期就抛 |
最后说两句
整套东西的立场就一句:MAF 出骨架,飞书 SDK 出血肉。框架管所有 Agent 都要面对的通用问题,SDK 管产品事实------哪些工具、什么权限、什么时间格式、哪条流式通道。边界干净,所以两边各自升级互不拖累。
最后再抬一手:同一套工具面还能以 MCP server 的形式跑出去(AddFeishuMcpServer,stdio 和 Streamable HTTP 两种传输)。Claude Code、Cursor 这些进程外的 Agent 接上来,用的工具、授权、审计跟进程内完全一致------你给团队搭的这个飞书助手,顺手也能让隔壁写代码的 Agent 用上同一套工具。工具面策展这份投入,回报比想象的大。
仓库在 MudFeishu,AI 部分的深度文档在 documents/AIAgent/ 下。想搭自己团队的工作搭子的,从 README 快速开始那节抄起,五分钟见雏形。