金禅
返回文章

整理自 GIAC 深圳分享

把 Agent 做成基础设施:多业务共享底座的工程实践

我们最早做 Agent 时,系统提示词、工具定义、skill 都写在代码里。产品想调整下一步推荐的时机,领域专家想补一条行业规则,运营想给生图工具加个宽高比,每件事都得走一遍开发和发版。

这些调整本来就需要高频发生。把它们绑在发版流程上,内容迭代很快就排起了队。

我负责团队的 Agent 平台建设,起初解决的也是这个问题:把 prompt、skill、工具和知识库从代码里拿出来,做成可管理的内容。随后补上在线验证、灰度、变更记录和回滚,让调整内容的人能自己完成“改、验、退”的闭环。

真正让工程复杂度上一个台阶的,是其他业务开始接入。同一套底座要支持不同的鉴权、计费、交互和模型;平台要继续演进,业务也要保留自己的控制权。光把配置放到页面上,已经不够了。

这篇文章想讲的,就是我们在执行引擎、端侧协议、多业务接入和 Context 治理上做过的几组选择,以及两个把设计问题暴露得很直接的故障。

一、让中断可配置,让恢复走同一条路

一次 Agent 执行可能停很多次:先等用户选方案,再等用户填表,最后调一个生图工具,等几十秒后的回调。每次暂停的原因不同,需要保住的东西却相似:消息上下文、执行状态,以及下一步从哪里继续。

我们的做法是把这些信息写成快照,存到 Redis 后释放线程。等外部事件回来,再恢复上下文继续执行。业务可以配置工具在哪里执行、执行前是否确认、最长等多久;引擎根据这些配置决定何时中断,恢复机制尽量保持统一。

SSE 事件也会持久化到 Redis。前端通过 messageId 订阅对应的流,不必把某一次浏览器连接当成执行本身。这样,在事件仍处于保留窗口、持久化记录可用的条件下,刷新页面、重新连接或打开另一个标签页,可以重新消费事件并恢复显示。连接的生命周期与任务的生命周期由此分开。

一条缺失的 ToolCall,为什么会让 Agent 重复提问

快照机制看起来很直观,我们却遇到过一个很隐蔽的问题:用户已经看过问询卡片,恢复执行后,Agent 又用相同参数调用了一次工具,反复问同一个问题。

原因藏在异步持久化的时序里。ToolCall 发起后,消息会在后台写入消息表。如果工具执行很快,又紧接着触发中断,异步写入还没有完成,快照里就少了这条 ToolCall。

恢复后的模型看不到自己调用过这个工具。对它来说,再调一次完全说得通。

这个问题只在“工具执行很快,随后立即中断”的组合下出现。大多数请求都正常,很容易让人先去怀疑提示词或者模型。

我们最终在中断前增加了 ToolCall 信息的同步写入,确保快照包含这次调用。恢复时,再从一直采用同步写入的独立 tool_call 表补全一次,作为兜底。

这次故障让我们把快照的要求具体化了:写入时同步确保恢复所需的信息完整,读取时保留重建依据。恢复正确性需要明确的写入顺序,不能寄希望于后台任务恰好赶得上。

快照也需要自己的资源预算

执行能恢复之后,Redis 又报了内存告警。多轮 messages 加上大型工具结果 JSON,单个快照 Key 在一次排查中达到了 254KB。频繁的中断写入,会把这件小事放大。大快照的读取还会拖慢恢复,表现在用户侧,就是 SSE 首字节迟迟不来。

我们做了三层处理:

  • 压缩。采用 DEFLATE level-1,优先控制关键路径开销。那次记录中,254KB 压到了 93KB,单次压缩约 0.5 毫秒。这是当时数据和环境下的结果,不能直接当成所有快照的压缩率或性能承诺。
  • 按状态设置 TTL。当时执行中的快照保留 24 小时,执行完成后缩短到 2 小时,留下短期回溯窗口。两个值都能通过配置中心调整。
  • 缓存过期后重建。在消息表和工具调用表的持久化记录完整时,可以从数据库重建上下文。代价是恢复更慢,因此它承担兜底角色。

我们也开始关注“重建占比”。这个比例突然升高,意味着需要排查缓存命中和保留策略,但不能直接得出 Redis 容量不足的结论。TTL 到期本来就是预期行为,要结合任务等待时长、TTL 设置和缓存状态一起看。

工具层沿着同样的边界设计。executionLocation、requireConfirmation、timeout 分别描述执行位置、确认要求和等待时限。已有 ComfyUI workflow 可以发布为 Agent 工具,外部能力通过 MCP 接入,再统一注册到管理平台。

在引擎已有能力覆盖的范围内,接入工具或调整确认策略可以通过配置完成。工具持续增加,核心中断与恢复链路仍然可以沿用。

二、前端参与执行,需要分清三种数据流

有些步骤天然发生在浏览器:让用户填写表单、操作编辑器、执行浏览器侧代码,或者把一份文档放进工作区。前端需要知道现在轮到谁执行、该提交什么、结果该放在哪里。

我们把这些场景分成组件工具、前端工具和 Artifact 三类,分别设计协议。

组件工具:把用户操作变成模型能读的上下文

组件工具的任务是收集用户输入。模型发起 ToolCall 后,后端根据 componentConfig 推送组件事件,前端按组件名渲染业务自己的 React 组件。用户操作完成,提交结果,执行才能继续。

这里用到一个小设计:Shadow Message。它对模型可见,但不重复显示在聊天界面里。

用户已经在组件里填过内容,聊天流没有必要再展示一遍相同输入;模型却必须知道用户填了什么。后端把提交结果包装成 Shadow Message 注入 Context,再恢复引擎,连接起这两种视角。

新增组件时,业务侧配置工具的输入输出定义,并在前端 SDK 中接入对应组件即可。组件长什么样由业务决定,后端仍沿用同一条恢复路径。

前端工具:把调用交给浏览器,再把结果交回来

前端工具使用的是浏览器的执行能力,比如操作画布编辑器、读取本地文件或执行 JavaScript。后端通过协议下发调用,前端在相应确认和权限条件下执行,再回传结果。

它与组件工具的分界在于交互目的:组件工具等待用户提供信息;前端工具需要浏览器完成操作。两者都可能暂停执行,但前端处理方式和结果含义不同,协议需要表达清楚。

Artifact:让产物找到合适的编辑器

图片、代码、文档、音频不一定适合一直留在聊天流里。有的需要自动打开,有的只需提示,还有的只是中间数据。我们把这些产物独立承载,再按类型交给对应视图。

平台会根据产物内容识别已支持的类型,编辑器插件声明自己能消费哪些类型,由平台完成匹配。后来接入音频生成工具时,产物被识别为 AUDIO,直接进入播放器,不需要再为这个工具单独注册一套路由。

这个机制减少了工具与编辑器之间的逐一绑定。类型识别能力和编辑器插件仍然有各自的支持范围,接入新类型时需要补齐对应能力;主执行流程不必因此反复改动。

三、参数交给配置,业务逻辑留出接管位置

多个业务共用一套引擎后,差异会逐渐分成两类。

模型、temperature、超时时间属于参数差异,可以按业务隔离配置,通过配置中心动态调整。鉴权链路、计费规则和内容安全要求则涉及执行逻辑,需要 Adapter 或 Hook,让业务提供自己的实现。

难点在于,不同能力的控制权不能机械地采用同一种分配方式。

接入:允许业务适配自己的链路

前端 SDK 封装流式通信、交互协议和鉴权接入,业务通过 npm 包或 CDN 引入。已有自己的请求或签名链路时,可以通过自定义请求实现接管;其余场景使用 SDK 支持的鉴权方式。

服务端通过 OpenAPI 开放能力。业务后端完成接入,平台按对应的业务配置选择模型、超时等参数,减少业务重复处理底层协议的工作。

计费:业务定规则,平台做路由

按 token、调用次数或包月计费,已经是几套不同规则。多模态工具又进一步拆开了计量单位:图片可能按分辨率,视频按时长,批量任务按数量。

我们让业务实现各自的计费适配器,定义扣费与退费逻辑。平台负责把请求路由到对应实现。工具级的计费解析器再按配置确定任务类型和计费事件,让不同工具进入合适的计费路径。

这样,业务计费规则可以独立调整,平台不需要把所有规则都理解一遍、写进主流程。

合规:业务可接管,平台仍有默认责任

水印和内容安全采用另一种安排:业务有更严格要求时,优先走业务 Hook;未接入相应 Hook 的场景,回落到平台默认服务。

接入层强调业务适配能力,计费层强调规则归属,合规层则需要默认兜底。都是扩展点,背后的责任并不一样。先确定谁对这项能力负责,再决定 Hook 的优先级和默认行为,接入设计才不会只剩下一堆接口。

四、Context 要同时算 token 和字节两本账

我们遇到过一次请求失败:上下文约 7 万 token,低于当时所用模型的窗口上限,发出去却直接收到 400。

排查后发现,请求体已经到了 77MB。几张图片转成 base64 后,每张约 5 到 10MB,再叠加多轮对话和大型工具结果 JSON,越过了接入网关的 30MB 限制。

模型窗口还有空间,请求链路却已经装不下了。

这次故障让我们把 Context 拆成两个独立预算:token 预算约束模型能处理多少内容,字节预算约束请求能否通过链路。图片尤其容易让这两个数字脱钩,不能根据 token 占用推断请求体大小。

Token 预算要在运行中持续计算

固定给历史消息分配一个比例并不稳妥。系统提示词、工具定义和 skill 本身就要占用窗口,而且业务随时会调整它们。我们的做法是每次请求先计算这些固定输入的实际占用,再把剩余空间分给历史消息。

裁剪也分两个时机。构建 Context 时,先压缩大型工具结果,按预算选取历史消息,对被裁掉的历史生成摘要。进入 ReAct 循环后,每次工具调用又会增加上下文,因此每轮之后还要检查。我们当时在达到预算的 85% 时触发运行时压缩或裁剪,比例和阈值都由业务按模型与场景配置。

两个细节很有用:

  • 对跨消息重复引用的图片去重,避免同一张图反复占用上下文。
  • 工具结果被压缩后,明确告诉模型当前内容是摘要,完整数据可通过 get_artifact 获取,减少它因为信息变少而重新调用原工具的机会。

摘要是在空间和信息完整度之间做取舍。保留完整产物的访问入口,才能让模型在需要时有机会取回细节。

字节预算需要发送前检查,也需要失败后的边界

在当时网关限制 30MB 的条件下,我们把主动压缩阈值设在了 20MB。发送前先估算体积,超出阈值就降低图片精度、移除早期图片或截断超长文本,提前留出余量。

如果仍然收到可识别的请求体过大错误,再压缩并重试一次。一次之后仍无法通过,就快速失败,避免对同一个限制反复重试。

图片处理则尽量前移:外部图片统一转存,通过多档降级控制目标体积,结合签名 URL 与缓存处理链接续期。模型支持的图片格式也在这一层归一化。

这些处理的目标,是让已支持来源、格式和模型组合下的图片更稳定地进入上下文。格式兼容、链接有效和链路容量都有边界,不能把归一化理解成任意图片都能送达的保证。上层业务可以少关心这些细节,平台则要把限制和失败路径处理清楚。

最后:新业务来了,应该改哪里

回头看,这几组设计都在回答同一个问题:哪些能力需要平台守住,哪些变化应该交给业务。

恢复链路由平台维护,中断策略通过工具配置表达;前端通信有统一协议,组件和编辑器由业务扩展;差异化逻辑通过 Adapter 接入,默认责任按能力分别设计;Context 预算由平台执行,具体比例和阈值留给场景调整。

这些边界很难一次就划对。快照里缺了一条 ToolCall,或者一个只有 7 万 token 的请求长到 77MB,都会提醒我们:抽象是否成立,最终要在真实的执行时序和资源限制里验证。

对我来说,一个很实用的检验方式是看下一个业务接入时,需要改动什么。如果主要变化落在配置、组件和适配层,经过验证的执行主链路能够继续复用,这套 Agent 底座才开始承担起基础设施的角色。