这个 bug 的案发报告特别简短:主流程 success,员工没收到图。

先交代背景。我们的 HR Agent 住在 Slack DM 里,员工问它社保、证明、假期流程这类问题,背后是 HR 知识库的 RAG——答案里经常该带一张指引图片。这套东西跑在 n8n 上, inbound 是 DM 消息,outbound 是 Slack 回复,听起来是个没什么野心的场景。直到那天,一位员工的图片回复悄无声息地消失了。

翻执行记录:主 workflow 标着成功,图片上传子执行里躺着一个 GCS 的 403 SignatureDoesNotMatch。把原始 signed URL 复制出来访问——200,图好端端的。再对比 Agent 传给上传工具的参数——URL 里 512 字符的 RSA 签名,被它转抄成了 510

两个字符,不多不少,刚好毁掉一次签名验证。V4 签名用的是 2048 位 RSA,签名本体 256 字节、十六进制固定 512 字符,截断一位、改编一次码,全部作废。模型的「差不多复制下来了」,在密码学面前一文不值。

512→510

让模型搬运授权材料,本身就是设计错误

排查之后的方案评审很有意思,因为被否掉的方案一个比一个像「正经主意」:

  • 提示词强化「原样复制 URL」:最便宜也最没用。模型的字符串搬运是统计过程不是字节拷贝,你可以把「逐字符保真」写进 prompt 一百遍,它该丢还是丢——这不是态度问题,是能力结构问题。
  • 把 RSA 签名截短或换编码:物理上不可能,V4 验签不认你自创的短签名。
  • 改用 GOOG4-HMAC-SHA256:签名缩到约 64 个 hex 字符,看起来缓解了对,但它引入长期 secret、要自己实现 canonical request,还受 XML API 限制——最要命的是,签名串还是得经过模型的手,病没除只是换了药。

真正被接受的方向是釜底抽薪:Agent 只传短 asset_idgs_uri 这类标识,由确定性的子工作流去拿对象、现场签名、完成上传。多了一次服务端解析,换来授权材料完全不进入模型上下文。这个原则的适用面比图片发送宽得多——token、签名、哈希、凭证,凡是要求逐字符保真的东西,都不该出现在模型的工具参数里。模型是推理引擎不是搬运工,让搬运工背保密协议,出了事不知道该怪谁。

顺带一提,binary 的边界也用同样的思路管着:下载和上传都被关在子工作流内部,跨 Agent 边界只返回 JSON 文件元数据。二进制数据不过模型上下文,一来防止序列化和内存风险,二来数据形态本来就不该在推理层停留——模型需要的是「这张图叫什么、上传成功没有」,不是那一百多万字节本身。

同一条链路还有个要一起修的洞:子工作流返回 {ok:false},主 execution 照样标 success,Agent 默默降级发了纯文字。「工具调用结束」和「业务交付成功」之间,隔着一个必须检查的结构化状态——上传工具返回的 ok 字段是交付契约的一部分,不是装饰。员工没收到图,系统说一切正常,这两个事实能同时成立,就是因为没人把 ok:false 翻译成业务失败。

入站:图片在三个数据形态里丢了一次

出站的问题修完,入站还有一个同款。员工上传截图问问题,Agent 回复「您好像没附图片」——可 Slack Trigger 明明收到了 486,086 字节的 PNG。这里插一句背景:RAG 侧返回图片引用从来没问题,丢图发生在「图片引用 → Slack 文件消息」的适配层——内容检索能力和渠道渲染能力是两份验收单,检索到了不代表员工看得见,这个道理当时花了一条链路才想明白。

一路追下去,图片丢了两次:Prepare Context 节点只保留 JSON 文本、把 files 字段丢了;就算保留下来,url_private_download 也只是个 URL 元数据,没人把它下载成 $binary;Agent 侧 passthroughBinaryImages 还是 false。模型看到的就是一句没有上下文的文字,然后老老实实承认没看见图。

这件事的形状和 signed URL 那事是镜像的:附件元数据、鉴权下载、模型可消费的 binary,是三种不同的数据形态,每一层转换都得有确定性组件负责。修复链路和出站对称——含图消息分流进下载节点,用现成的 Slack credential 访问私有 URL(凭据不经过模型,也不暴露内部 URL),得到的 binary 和文本上下文合并进 Agent。一期只处理第一张图,多图、超大文件、非图片 MIME 全在待办里躺着——但至少,「看见」这件事现在是真的看见了。

顺带的两个小教训也记一下。一是工具拆分的理由:Understand Url 是视觉理解工具,不是通用 URL 解析器——它不吃网页、不吃 PDF、不碰需鉴权链接,把「看懂图」和「拿到文件」混在一起只会两头都做不好。二是 workflow API 更新的暗坑:这次修复过程中,JSON 表达式的转义和字面量 \n 问题连着出了两次,都是发布后立即回读才发现——写进去的配置和存下来的配置是两回事,任何通过 API 改 workflow 的操作,后面都应该跟一个「序列化后回读」的步骤,这跟写完数据库要回读是一个道理。

目标地址与身份:服务端注入,不许模型选

HR 场景比普通 Agent 多一层敏感:员工地区、租户、对话对象,全是隐私边界。所以两条规则被钉死在架构里:Slack channel 由当前 DM 上下文固定注入,Agent 不能自己选收件人;员工的 region/tenant 从可信映射获得,tenant/datastore 不允许模型输入覆盖。

为什么值得单独说?因为「让模型自己填目标」的默认实现实在太顺手了——工具参数里留个 channel 字段,模型大多数时候填得对,直到哪天它从上下文里猜错了一个字母,HR 政策的回复就发进了别人的 DM。隐私事故的可怕之处不在于频率,在于每一次都是真的。副作用的目标地址和授权材料一样,属于「不该过模型的手」清单——模型可以决定「要不要发」「发什么」,「发给谁」得由服务端上下文说了算。

下载侧的对称边界也顺手立了:上传工具只接受无需额外鉴权的公开 HTTPS URL——gs://、过期的 Slack 链接、要登录的页面一律不接,域名 allowlist、下载大小上限、MIME allowlist 这些 SSRF 防线还在待办清单上排队。原则还是那个:对外部资源的获取策略由代码决定,不由模型现场发挥。

边界三件套

记忆分桶:隔离边界编码在 key 里

会话记忆的设计走了条不太起眼但挺讲究的路。最初的 session key 是 slack-dm:{channel},不同员工隔离没问题,但同一员工的所有话题——问社保的、问证明的、问假期的——全进同一个记忆桶,越聊越串味。反过来按消息时间戳建 key 呢?每条消息都失忆,那不叫记忆叫便签。还想过给 /new 配随机新 key——不删数据很优雅,但得持久保存「当前 generation」,等于在 key 之外又引入一份状态,复杂度劝退。

最后落在日粒度:slack-dm-HRAgent-{channel}-{YYYY-MM-DD},日期按 Slack 消息时间戳、以 Asia/Shanghai 计算——时区写错的话,换桶时刻会漂到北京时间早上八点,员工深夜聊的天算进第二天。/new 命令在进 Agent 之前就被确定性分流到 Memory Manager,只清当前 key 的那一桶,删完回一句「已开始新对话」。几个细节都不许糊弄:/new 本身不进模型上下文(重置指令不该成为谈资);删除范围限定到当前桶(清表或清别人记录是不可接受的);旧格式记录不做破坏性迁移,换 key 之后它们只是不再被读到——非破坏迁移的优雅之处就在于,后悔药永远在

这套方案后来在做平台迁移评估时又照出一个小坑:原实现用 thread_ts || ts 做 thread key,新平台的 fallback 是 message.threadId || channelId——在顶层 DM 里,后者可能把同一个员工的多次独立对话合并进同一条 memory thread。记忆隔离这种东西,key 的一个 fallback 差别就是隐私边界的差别——昨天问病假和今天问离职证明被拼进同一条「上下文」,模型会拿昨天的敏感话题当今天的背景。评估因此立了条规矩:真实事件验证过 thread 形状之前,不许下线旧的清记忆机制。还有切流风险:新旧系统并行期间不做去重,同一个员工会收到两份回复——社死现场的工程版本。

HR 场景还有一层产品级边界顺手立了:敏感问题该拒答就拒答、该升级 People Team 就升级,交付不上图或答不上来时禁止「虚假完成」——宁可回一句「这个得找真人」,也不能让模型现场编一份听着合理的 HR 政策。

迁移搬的不是节点,是整套契约

说到迁移,这个评估本身也值得一写。直觉方案是「把 n8n 的 Agent 节点换成 Mastra Agent」——29 个节点的 workflow,只换中间那一个,多快好省。但被否得很干脆:迁移的对象不是 Agent 节点,是 Slack 接入、可信员工身份、RAG 权限、memory 和交付契约这一整套东西。目标形态是「通用 Slack shell → 服务端可信员工身份 → 独立 hr-support Application → tenant-scoped HR RAG 与 thread memory → 确定性 Slack 交付」——每一环都是契约,不是实现细节。

有个具体的坑能说明为什么不能「只换节点」:平台现有的 Slack handler 是给 Qasey 硬编码的,HR 应用直接接上去,会继承错误的工具集和渠道配置——外壳合身,里子是别人的。这也是为什么前面所有「服务端注入」的规则在迁移清单里排在最前:身份、租户、交付目标这些「不许模型碰」的边界,必须先于任何 Agent 能力迁移落位,否则就是把装修搬进了一堵没砌的墙里。

这套边界怎么验收

隐私和交付边界的验收,矩阵感比单点测试重要:受控测试频道里跑通一张图的下载、上传、permalink 和权限全链路;用测试账户验四种记忆边界——同员工同日、跨日、不同员工、/new;脱敏 golden questions 对比政策正确性、引用、拒答/升级、图片交付和延迟;切流演练必须确认无双回复。负向矩阵也得配上:Slack App 缺 files:write 时结构再对也会上传失败,URL 过期、要鉴权、非 HTTPS、MIME 不支持都应该得到结构化失败而不是奇怪的降级——这些「不该通的确实不通」,要逐条验不能靠推理。每一条都是「真实 DM 不敢乱来」的替代方案——测试用真实员工做代价,是最大的不专业。

一句话收束:给 Agent 划边界,很多时候不是教它「该做什么」,而是决定「哪些东西根本不过它的手」——授权材料、目标地址、身份字段、他人记忆。模型的可靠性可以用来回答问题,不该用来搬运需要逐字保真的秘密。