在飞书 SDK 上用 Microsoft Agent Framework 搭一个能干活的工作搭子

为什么想做一个能干活的东西

飞书上的 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 快速开始那节抄起,五分钟见雏形。

相关推荐
张建飞(Frank)1 小时前
从零构建Agent(十):Model管理
人工智能·agent
沐曦股份MetaX1 小时前
沐曦股份亮相CCF第三届算力活动:重磅发布Token算力普惠计划,赋能科研新时代
人工智能·科技·ai·开源·gpu算力
七牛云行业应用2 小时前
DeepSeek Harness(dsh)安装完整指南:Windows、macOS、Linux 三平台 + 桌面端与 Web 端全覆盖
linux·windows·macos·ai·ai编程
小七-七牛开发者2 小时前
Meta 如何改造工程环境,让 Agent 自己跑开发 Loop
meta·agent·工程实践·ai 编程·研发工具链
京东云开发者2 小时前
OxygenData-Vibe搭建:用 AI Coding重新定义数据看板搭建
数据分析·agent
一个天蝎座 白勺 程序猿2 小时前
AiiOnly + WorkBuddy 搓了个文本对比小工具:选型、接入、成本一次讲清
ai·ai集成·ai使用·workbuddy·aiionly·文本对比
小易老师AI实战2 小时前
LangChain create_agent 与 LangGraph 底层原理及实战对比
langchain·agent·大模型应用开发·langgraph
AI直播技术杂谈2 小时前
九点整那场,讲品顺序是前一天下午就锁定的
网络·python·ai·秒播
DigitalOcean2 小时前
WorkBuddy 接入自定义模型教程:配置 GPT、Claude 等大模型
llm·agent