首页 / 资讯中心 / 文章详情

Agent开发本质是客户端开发:状态管理与事件流工程实践

Agent开发本质是客户端开发:状态管理与事件流工程实践 ★ FEATURED ARTICLE
1. 为什么说 Agent 开发骨子里就是客户端开发第一次听到“Agent 开发其实就是客户端开发”这个说法很多人会愣一下。毕竟现在满世界都在讲大模型、讲推理、讲工具调用怎么突然就绕回客户端了我刚开始做 Agent 相关项目的时候也有同样的疑惑直到连续踩了几个坑之后才慢慢回过味来——这个类比不是玩梗而是把 Agent 开发这件事从玄学拉回到了工程。先把结论摆出来Agent 的本质是一个运行在用户侧、需要管理状态、需要处理异步事件、需要和外部服务通信、需要做错误恢复的交互式程序。你把这段话里的“Agent”换成“App”是不是毫无违和感这就是为什么我说它骨子里是客户端开发。我们平时说的“大模型能力”其实更像是客户端背后的那个远程服务。模型本身不负责记住用户上一轮说了什么不负责在工具调用失败之后重试不负责把流式返回的 token 拼成一句完整的话再渲染到界面上。这些事情全都是 Agent 这一层在干。而 Agent 这一层所处的位置恰好就是传统客户端所处的位置夹在用户和远端服务之间负责体验、负责状态、负责容错。很多人做 Agent 的第一个误区就是把它当成一个“后端服务”来写。写一个 HTTP 接口收到请求调模型返回结果。跑 demo 没问题一旦要支持多轮对话、要支持中途打断、要支持工具调用的中间状态展示立刻就崩了。因为这些东西本质上都是有状态的交互问题而有状态交互正是客户端开发的主场。所以这篇文章我想聊的不是“怎么调 API”而是怎么用客户端开发的思维去组织一个 Agent 项目。我会从状态管理、事件循环、渲染与流式、错误恢复、工具调用的边界这几个角度把这件事拆开讲清楚。如果你之前写过后端、写过前端、或者写过移动端你会发现很多经验可以直接迁移过来甚至比学 prompt 工程更有用。2. Agent 的状态管理为什么全局变量会把你坑死2.1 会话状态到底包含哪些东西先问一个问题一个正在运行的 Agent它的“状态”到底有哪些很多人第一反应是“对话历史”。但对话历史只是冰山一角。我实际做下来一个 Agent 的运行时状态至少包含这几类对话消息序列用户消息、助手消息、工具调用请求、工具返回结果这些是有严格顺序的。当前执行阶段是在等用户输入还是在等模型返回还是在等工具执行还是在等用户确认某个危险操作。工具调用的中间态一个工具可能被调用了但还没返回或者返回了一部分或者失败了需要重试。用户侧的临时输入比如用户正在打字但还没发送或者用户点了“停止生成”。会话级的配置当前用哪个模型、温度多少、开启了哪些工具、上下文窗口还剩多少。你看这些东西没有一个是“无状态”的。如果你用写后端接口的思路每次请求都重新构造一遍那用户中途打断、工具调用到一半刷新页面、多轮对话里模型突然要求调用工具——这些场景你一个都处理不了。2.2 状态该放在哪一层这里有个很实际的取舍状态放在内存里还是放在持久化存储里还是两者都有我的经验是分层。会话的“热状态”——也就是当前这一轮正在跑的东西——放在内存里用一个明确的 Store 来管理。会话的“冷状态”——历史消息、用户配置——落到本地存储或者远端。这样做的理由是热状态变化极其频繁每来一个 token 都可能触发更新如果每次都写磁盘性能直接崩掉而冷状态需要跨会话、跨设备保持一致必须持久化。具体到实现我一般会定义一个类似这样的结构以 TypeScript 为例其他语言思路一样interface AgentSession { id: string; messages: Message[]; // 完整消息序列 phase: idle | thinking | tool_running | awaiting_user; pendingToolCalls: Mapstring, ToolCallState; abortController: AbortController | null; config: SessionConfig; }注意那个abortController。这是客户端开发里非常经典的一个东西用来取消正在进行的异步操作。Agent 场景下它对应的是“用户点了停止生成”。如果你没有这个东西用户点了停止模型还在后台哗哗地返回 token你的界面和状态就彻底对不上了。2.3 状态更新的单向数据流客户端开发里有一个被验证过无数次的模式单向数据流。事件触发 actionaction 修改 storestore 变化触发视图更新。Agent 开发完全适用。我见过太多 Agent 项目状态更新是散落在各处的模型返回的回调里改一下工具返回的回调里改一下UI 事件里再改一下。结果就是状态不一致界面显示“正在思考”实际上早就返回完了或者消息列表里少了一条工具结果模型下一轮直接懵掉。正确的做法是所有状态变更都必须经过一个统一的入口。模型返回的 token、工具执行的结果、用户的输入、用户的打断全部包装成事件丢进同一个 reducer 或者 store 的 action 里处理。这样你才能保证状态机是收敛的不会出现“两个地方同时改同一个字段”的竞态。提示如果你用的是 React不要图省事把 Agent 状态塞进组件的 useState。组件一卸载状态就没了而且多个组件之间同步状态会非常痛苦。用一个独立的 storeZustand、Redux、Pinia 都行组件只负责订阅和渲染。3. 事件循环与流式渲染Agent 的“心跳”在哪3.1 流式返回不是“优化”是刚需很多人把流式返回当成一个体验优化觉得“反正最后结果一样不流式也行”。这个想法在 Agent 场景下是错的。原因有两个第一Agent 的一轮响应可能持续几十秒甚至几分钟中间还要调用工具。如果不流式用户面对的就是一个转圈圈的界面完全不知道发生了什么体验极差。第二更重要的是流式返回是 Agent 状态机推进的信号。模型返回的每一个 token、每一个工具调用的开始和结束都是驱动状态变化的事件。如果你等整个响应结束再处理那工具调用就没法“边生成边执行”整个 Agent 就退化成了一个普通的问答接口。所以流式不是可选项是 Agent 架构的基础设施。3.2 把流式数据当成事件流来处理我习惯把模型返回的流式数据抽象成一个事件流每个事件有明确的类型事件类型含义对应的状态变化text_delta一段文本增量追加到当前助手消息tool_call_start开始调用某个工具创建 pending 工具调用tool_call_args工具参数增量更新工具调用参数tool_call_end工具调用参数完整触发工具执行tool_result工具执行结果追加工具结果消息done本轮结束状态回到 idleerror出错进入错误恢复流程这样抽象之后你的渲染层只需要根据事件类型做对应处理逻辑非常清晰。而且这套事件流可以很方便地做录制和回放——调试 Agent 的时候把一次完整的事件流录下来之后反复回放比每次重新调模型高效得多。3.3 渲染节流别让每个 token 都触发重渲染这是一个非常实际的性能问题。模型一秒可能返回几十个 token如果每个 token 都触发一次界面重渲染在低端设备上直接卡成幻灯片。解决办法是节流。把 token 先攒在一个缓冲区里用 requestAnimationFrame 或者一个固定间隔比如 16ms 或者 50ms批量刷到界面上。这样视觉上依然是“逐字出现”的效果但渲染次数降低了一个数量级。let buffer ; let rafId null; function onTextDelta(delta) { buffer delta; if (rafId null) { rafId requestAnimationFrame(() { appendToMessage(buffer); buffer ; rafId null; }); } }这个技巧在客户端开发里是老生常谈但搬到 Agent 场景下依然有效。我实测下来加了节流之后长文本生成的界面流畅度提升非常明显。3.4 工具执行不能阻塞事件循环工具调用是 Agent 和普通聊天最大的区别。一个工具可能要跑几秒甚至几十秒比如查数据库、调外部接口。如果你在主线程里同步等它返回整个界面就冻住了。正确做法是把工具执行放到独立的异步任务里主事件循环继续处理其他事件。工具执行完了再通过事件的方式把结果丢回来。这样即使用户在工具执行期间继续输入、或者点了停止界面都能正常响应。这里有个细节工具执行期间用户能不能打断我的建议是能。用户点了停止应该立刻取消正在执行的工具如果工具支持取消的话并把状态回滚到一个一致的状态。这又回到了前面说的 abortController 的用法。4. 工具调用的边界处理Agent 最容易翻车的地方4.1 工具不是函数是“有副作用的远程调用”新手最容易犯的错误是把工具当成一个普通的本地函数来调。本地函数调用是可靠的、同步的、幂等的。但工具调用不是它可能超时、可能返回格式不对、可能执行了一半失败、可能被重复调用。所以工具调用的每一层都要做防御。我一般会在三个地方做检查调用前校验参数是否符合 schema参数不合法直接返回错误不要浪费一次远程调用。调用中设置超时超时后取消并返回超时错误。调用后校验返回结果的结构结构不对就当成失败处理不要把脏数据塞回给模型。4.2 工具结果的“回灌”格式很关键工具执行完之后结果要作为一条消息塞回对话历史让模型在下一轮能看到。这条消息的格式非常重要因为模型是靠着它来理解“我刚才调用的工具返回了什么”。我踩过的一个坑是工具返回了一个很大的 JSON我原样塞回去了结果直接把上下文窗口撑爆。后来改成只回灌模型真正需要的字段并且在结果外面包一层结构化的描述告诉模型这是什么工具、执行成功还是失败。{ tool: search_database, status: success, result: { count: 3, items: [..., ..., ...] } }这样模型看到的不只是一坨数据而是有语义的“某工具执行成功返回了这些内容”。实测下来模型对工具结果的利用率明显提高。4.3 并行工具调用的顺序问题有些模型支持一次返回多个工具调用让你并行执行。这听起来很美好但实际做起来有个坑并行执行的结果回灌顺序必须和模型请求的顺序一致。因为模型在生成这些工具调用的时候是有隐含顺序假设的。如果你并行执行完按完成时间先后回灌模型下一轮看到的顺序就乱了可能导致它理解错因果关系。所以我的做法是并行执行但收集结果时按原始请求顺序排列再一起回灌。4.4 危险操作的确认机制这是 Agent 和普通客户端最不一样的地方Agent 可能会执行有副作用的操作比如删文件、发请求、改数据。这些操作不能让它自动执行必须有一个确认环节。我的做法是在工具定义里加一个requiresConfirmation标记。当模型请求调用这类工具时Agent 不直接执行而是把状态切到awaiting_user在界面上弹出一个确认框把工具名和参数展示给用户。用户确认了才执行拒绝了就把拒绝结果回灌给模型。这个机制看起来简单但它是 Agent 从“玩具”走向“可用”的关键一步。没有它你根本不敢让 Agent 碰任何真实数据。5. 错误恢复Agent 跑飞了怎么救回来5.1 错误分三类处理方式完全不同Agent 运行过程中会出各种错但不能一视同仁。我一般分成三类可重试错误网络抖动、限流、临时超时。这类错误直接重试加个指数退避就行。可恢复错误模型返回了格式不对的工具调用、工具参数校验失败。这类错误应该把错误信息回灌给模型让它自己修正。不可恢复错误上下文超长、模型返回了完全无法解析的内容、工具连续失败多次。这类错误要中断当前轮把状态切回 idle并给用户一个明确的提示。分清楚这三类你的错误处理逻辑才不会变成一团乱麻。5.2 把错误信息回灌给模型让它自己修这是 Agent 开发里一个非常有意思的点模型本身就是一个错误恢复器。当工具调用失败时与其你自己想办法不如把错误信息原样告诉模型让它重新生成一次调用。比如模型调用了一个工具但参数少了一个必填字段。你把校验错误回灌给它“工具 X 调用失败缺少参数 Y”。模型下一轮大概率会补上这个参数重新调用。这比你在代码里写一堆兜底逻辑要优雅得多。但要注意回灌错误信息也要有次数限制。不能让模型无限重试否则会陷入死循环。我一般设置最多重试 3 次超过就中断并提示用户。5.3 上下文超长的处理策略这是所有 Agent 项目迟早会遇到的问题对话轮次多了上下文窗口满了。这时候模型会直接报错整个会话就卡住了。处理策略有几种我按推荐程度排序滑动窗口 摘要保留最近 N 轮完整对话更早的对话用模型生成一段摘要代替。这样既保留了关键信息又控制了长度。工具结果裁剪工具返回的大块数据只保留模型需要的部分其余丢弃。强制开启新会话实在不行就提示用户“当前会话太长建议开启新会话”把历史归档。第一种是效果最好的但实现也最复杂。我的经验是摘要的 prompt 要专门设计让它只保留“和当前任务相关的信息”而不是泛泛地总结。5.4 用户打断后的状态一致性用户点“停止生成”之后Agent 的状态必须回到一个一致的状态。什么叫一致就是消息序列里不能有半截的助手消息不能有悬空的工具调用。我的做法是打断时如果当前助手消息已经有部分内容就把它标记为“已中断”并保留如果工具调用正在进行就取消它并回灌一个“用户中断”的结果。这样下一轮模型看到的历史是完整的不会因为缺了一条工具结果而困惑。6. 从客户端开发迁移过来的几个实用经验6.1 用“乐观更新”提升交互感客户端开发里有个经典技巧叫乐观更新用户点了赞界面立刻显示已赞不等服务器返回。Agent 场景下也可以用。比如用户发送消息后立刻把用户消息渲染到界面上同时把状态切到“思考中”。不用等模型返回第一个 token 才更新界面。这样用户感觉是“秒回”体验好很多。工具调用也一样。模型请求调用工具时立刻在界面上显示“正在调用 XX 工具”而不是等工具执行完才显示。用户能看到 Agent 在干什么焦虑感大大降低。6.2 把 Agent 的“思考过程”可视化这是 Agent 相比普通聊天最值得做的一个体验点。模型在决定调用哪个工具、传什么参数的时候其实是有推理过程的。如果能把这个过程展示出来用户会觉得 Agent 很“透明”信任感更强。具体做法是把模型的推理文本如果有的话和工具调用请求分开渲染。推理文本用浅色小字展示工具调用用卡片展示。用户一眼就能看出 Agent 在想什么、要做什么。6.3 本地缓存和离线能力客户端开发很重视本地缓存Agent 也一样。比如工具的定义、模型的配置、历史会话都可以缓存在本地。这样即使网络不好用户也能看到历史记录只是不能发起新的请求。更进一步一些简单的工具比如计算器、文本处理完全可以本地执行不需要走远程。这样既快又省。我在一个项目里把常用的几个纯计算工具做成了本地执行响应时间从几百毫秒降到了几毫秒。6.4 版本兼容和灰度Agent 依赖的模型和工具是会变的。模型升级了prompt 可能要调工具接口变了调用代码要改。如果你没有版本管理一次升级可能让所有用户都挂掉。我的做法是给 Agent 的配置加版本号模型、prompt、工具集都绑定到某个版本。新版本先灰度给一小部分用户观察没问题再全量。这套东西在客户端开发里是标配搬到 Agent 上同样适用。7. 一个最小可用的 Agent 客户端骨架说了这么多原理最后给一个可以直接参考的骨架。用伪代码表示语言无关class AgentClient { store: AgentStore; eventQueue: EventQueue; toolRegistry: ToolRegistry; async sendUserMessage(text) { store.appendMessage({ role: user, content: text }); store.setPhase(thinking); await this.runLoop(); } async runLoop() { while (store.phase ! idle) { const response await this.callModel(store.messages); for await (const event of response.stream) { this.eventQueue.push(event); this.handleEvent(event); } if (store.hasPendingToolCalls()) { await this.executeTools(); } else { store.setPhase(idle); } } } handleEvent(event) { switch (event.type) { case text_delta: store.appendToCurrentMessage(event.delta); break; case tool_call_start: store.createPendingToolCall(event.id, event.name); break; case tool_call_end: store.finalizeToolCall(event.id, event.args); break; // ... } } async executeTools() { const calls store.getPendingToolCalls(); const results await Promise.all(calls.map(c this.runTool(c))); // 按原始顺序回灌 for (const r of results) { store.appendMessage({ role: tool, content: r }); } } }这个骨架里store 负责状态eventQueue 负责事件流转toolRegistry 负责工具管理。三者职责清晰互不耦合。你可以根据自己用的框架替换具体实现但整体结构是通用的。8. 写在最后的一点个人体会做了几个 Agent 项目之后我最大的感受是模型能力决定上限工程质量决定下限。一个 prompt 写得再花哨的 Agent如果状态管理一塌糊涂、错误处理全靠 try-catch 兜底、流式渲染卡成 PPT用户用两次就跑了。反过来一个 prompt 中规中矩但工程扎实的 Agent反而能稳定地解决实际问题。所以如果你正在做 Agent 开发我建议你把至少一半的精力放在工程上而不是全花在调 prompt 上。状态机、事件流、错误恢复、工具边界这些东西看起来不“AI”但它们才是决定你的 Agent 能不能真正用起来的关键。客户端开发积累了十几年的这些经验现在正好可以拿来用。
阅读完成 · 觉得有帮助?
咨询建站