☰
Agent开发本质是客户端开发:状态管理与异步工程实践
2026/10/20 16:23:12 网站建设 项目流程

1. 为什么说 Agent 开发骨子里就是客户端开发

第一次听到"Agent 开发其实就是客户端开发"这个说法,我愣了几秒,然后拍了下大腿——这话说得太准了。过去大半年我一直在折腾各种 Agent 项目,从最简单的对话机器人到带工具调用的复杂工作流,踩了无数坑之后回头看,发现真正让我卡住的从来不是模型能力,而是那些客户端开发里早就被讲烂了的老问题:状态怎么管、异步怎么处理、UI 怎么响应、错误怎么兜底。

很多人一上来就把 Agent 当成一个"AI 问题"来解,觉得只要 prompt 写得好、模型选得对,一切就水到渠成。但实际跑起来你会发现,prompt 只是其中一小块拼图。真正决定一个 Agent 能不能用得舒服、跑得稳定的,是它作为一个"客户端"的工程素质。它要接收用户输入、要维护会话状态、要调用外部工具、要处理超时和重试、要把中间过程反馈给用户——这些事,和十年前我们写一个桌面客户端或者移动 App 时面对的问题,本质上是同一类。

这篇文章我想把这件事掰开揉碎讲清楚。核心关键词就是Agent 开发和客户端开发,我会从架构分层、状态管理、异步与流式、工具调用、错误处理、可观测性这几个角度,把 Agent 开发里那些"客户端味"最浓的地方一个个拆给你看。不管你是刚接触 Agent 的新手,还是已经写过几个 Demo 但总觉得哪里不对劲的开发者,应该都能从中找到共鸣。我的目标不是给你一个万能框架,而是帮你建立一种"用客户端思维做 Agent"的视角,这个视角一旦建立起来,很多之前觉得玄乎的问题会突然变得清晰。

先说结论:Agent 的本质是一个有状态的、事件驱动的、需要和外部世界交互的客户端程序。模型只是它内部的一个"计算引擎",就像客户端里的渲染引擎或者业务逻辑层。你把模型供起来当神拜,它就处处跟你作对;你把它当成一个普通的、有点慢、有点不稳定的依赖服务来对待,反而一切都顺了。

2. Agent 的架构分层:和客户端几乎一一对应

2.1 从"输入-处理-输出"看两者的同构性

我们先把一个典型客户端的分层摆出来。一个标准的客户端应用,通常有这么几层:表现层负责渲染界面和接收用户操作,状态层管理应用数据,业务逻辑层处理具体功能,服务层负责和外部系统通信,基础设施层管网络、存储、日志这些底层能力。

现在把 Agent 拆开看,你会发现对应关系惊人地整齐。表现层对应的是对话界面或者调用方看到的输出流;状态层对应的是会话历史、上下文窗口、工具调用记录;业务逻辑层对应的是 Agent 的决策循环——也就是那个"思考-行动-观察"的循环;服务层对应的是模型 API 调用和工具调用;基础设施层对应的是 token 计数、重试、限流、日志追踪。

这个对应关系不是巧合。因为 Agent 和客户端面对的是同一类根本问题:在一个不可靠的环境里,维护一份可靠的状态,并把它以用户能理解的方式呈现出来。客户端面对的是网络不稳定、服务端会挂、用户操作千奇百怪;Agent 面对的是模型会幻觉、工具会超时、用户输入同样千奇百怪。问题的结构一样,解法自然也就相通。

我见过太多 Agent 项目,把所有的逻辑都塞在一个巨大的函数里:调模型、解析输出、执行工具、拼上下文、再调模型,全糊在一起。这种写法在 Demo 阶段没问题,一旦要加个重试、加个中间状态展示、加个多轮记忆,立刻就变成一团乱麻。这跟当年把网络请求、数据处理、UI 更新全写在一个 Activity 或者一个事件回调里的"面条代码"是一模一样的病。

2.2 决策循环就是客户端的事件循环

客户端开发里有个核心概念叫事件循环:程序不停地等待事件、处理事件、产生新事件,周而复始。Agent 的决策循环本质上就是这个东西。

一个 Agent 跑起来是这样的:等待用户输入(事件),把输入和上下文一起送给模型(处理),模型返回要么是最终答案要么是工具调用请求(产生新事件),如果是工具调用就执行工具(处理),把结果塞回上下文再送给模型(循环),直到模型给出最终答案(退出循环)。

你看,这不就是一个典型的事件驱动循环吗?用户输入是事件,模型输出是事件,工具返回也是事件。区别只在于,客户端的事件循环里,事件处理器是你写的确定性代码;而 Agent 的事件循环里,有一个环节的处理器是概率性的模型。这个区别很重要,但它不改变循环本身的结构。

理解了这一点,你就能明白为什么 Agent 开发里"循环控制"这么关键。就像客户端要防止事件循环卡死、要处理事件风暴一样,Agent 也要防止无限循环、要限制最大迭代次数、要在合适的时机中断。我踩过的一个坑就是没设迭代上限,结果模型在两个工具之间来回调用,烧了一大笔 token 才停下来。后来我学乖了,所有循环都强制加一个max_iterations,默认给个 10 到 15,超了就返回当前最好的结果并提示用户。

2.3 把模型当成"慢速不可靠的依赖服务"

这是我想强调的最重要的一条心法。很多开发者对模型有一种不切实际的期待,觉得它应该又快又准又稳定。一旦模型返回慢了、格式错了、内容幻觉了,就手足无措。

正确的姿势是:把模型当成一个网络请求来对待。它可能超时,可能返回错误码,可能返回格式不对的内容,可能这次好用下次抽风。你要做的,就是给它加上所有你给网络请求加过的东西:超时控制、重试策略、降级方案、结果校验。

具体来说,我通常会给模型调用包一层统一的客户端:

class ModelClient: def __init__(self, timeout=30, max_retries=3): self.timeout = timeout self.max_retries = max_retries def call(self, messages, tools=None): for attempt in range(self.max_retries): try: resp = self._do_request(messages, tools, timeout=self.timeout) return self._validate(resp) except TimeoutError: if attempt == self.max_retries - 1: raise time.sleep(2 ** attempt) except FormatError: # 格式错误不重试,直接走修复逻辑 return self._repair(resp)

这层封装看起来简单,但它把"模型不可靠"这件事隔离在了一个地方。上层业务代码只管调用client.call(),不用关心底下重试了几次、超时了怎么办。这就是客户端开发里"服务层封装"的思路,一模一样。

3. 状态管理:Agent 最容易翻车的地方

3.1 上下文窗口就是你的"内存"

客户端开发里,内存管理是个永恒的话题。内存有限,你得决定什么留在内存里、什么写到磁盘、什么直接丢掉。Agent 的上下文窗口就是它的内存,而且这个内存比你想的还要紧张。

一个多轮对话的 Agent,上下文里会堆积这些东西:系统提示词、历史对话、工具定义、工具调用记录、工具返回结果、当前用户输入。这些东西加起来,很容易就撑爆上下文窗口。撑爆之后要么报错,要么模型开始"遗忘"前面的内容,表现就是答非所问、重复提问、忘记已经确认过的信息。

我在一个项目里遇到过特别典型的情况:用户和 Agent 聊了二十多轮,前面确认过的订单号、地址、偏好,到后面 Agent 全忘了,又开始重新问。用户直接炸了。这个问题的本质,就是没有做上下文管理,把所有历史一股脑塞进去,结果要么超限要么被截断。

解决思路和客户端的内存管理如出一辙:分级存储 + 按需加载。我的做法是把上下文分成三层:

层级内容策略
常驻层系统提示词、核心工具定义永远保留,精简到极致
摘要层历史对话的压缩摘要定期生成,替换原始对话
临时层最近几轮原始对话、当前工具结果滑动窗口,超出就丢弃或摘要

摘要层是关键。当对话超过一定轮数,我会调用模型把前面的对话压缩成一段结构化摘要,比如"用户已确认订单号 X,收货地址 Y,偏好 Z"。然后把这几十轮原始对话替换成这一段摘要。这样既保住了关键信息,又大幅压缩了 token 占用。

3.2 会话状态和业务状态要分开

这是另一个我踩过的坑。一开始我把所有状态都塞在对话历史里,觉得这样"自然"。结果发现,有些状态是需要精确读写的,比如"用户当前处于哪个流程步骤""已经收集到了哪些字段",这些用自然语言存在对话里,模型每次理解都可能不一样,非常不可靠。

后来我改成双轨制:对话历史归对话历史,业务状态单独用一个结构化的对象管理。

class SessionState: def __init__(self): self.conversation = [] # 对话历史,给模型看 self.slots = {} # 结构化槽位,给代码用 self.step = "greeting" # 当前流程步骤 self.flags = set() # 各种标记位

slots里存的是确定性的业务数据,比如{"order_id": "12345", "address": "..."}。这些数据由代码精确读写,不依赖模型理解。而对话历史只负责给模型提供语境。两者通过一个"状态同步"环节连接:每轮结束后,从模型输出里抽取需要更新的槽位,写进slots。

这样做的好处是,流程控制变得完全可控。我可以写if state.step == "confirm" and "order_id" in state.slots:这样的确定性判断,而不是让模型去"回忆"用户是不是确认过了。这就是客户端开发里"UI 状态和业务状态分离"的经典实践,搬到 Agent 上一样管用。

3.3 状态持久化:别忘了 Agent 也会"崩溃"

客户端要考虑进程被杀、应用重启后状态怎么恢复。Agent 也一样。一个跑了半小时的长任务,如果中间因为网络问题或者服务重启挂了,用户肯定希望它能接着跑,而不是从头再来。

我的做法是给会话状态加一个轻量的持久化层。每轮循环结束后,把SessionState序列化存到本地文件或者数据库。重启时先尝试加载,加载到了就恢复现场。这里有个细节要注意:工具调用的副作用要幂等。比如一个"发送邮件"的工具,如果状态显示已经发过了,恢复后就不要再发一次。我通常会给每个工具调用记录一个唯一 ID 和执行状态,恢复时先检查。

def execute_tool(tool_name, args, call_id, state): if call_id in state.completed_calls: return state.completed_calls[call_id] # 已执行,直接返回缓存结果 result = tools[tool_name](**args) state.completed_calls[call_id] = result persist(state) return result

这个模式在客户端里叫"幂等操作 + 结果缓存",在分布式系统里叫"恰好一次语义"。名字不同,思路一样。

4. 流式输出与异步:用户体验的生死线

4.1 为什么必须做流式

客户端开发里有个铁律:任何超过 200 毫秒的操作,都要给用户反馈。模型生成一段回答可能要好几秒甚至几十秒,如果让用户干等着,体验是灾难性的。

流式输出就是解决这个问题的。模型一边生成,你一边把内容推给用户,用户看到文字一个个蹦出来,感知到的等待时间大幅缩短。这跟客户端里"先显示骨架屏再加载内容""图片渐进式加载"是同一个道理——用过程的可感知性来对抗总时长的不可控。

实现流式,核心是把模型的流式接口和你的输出通道对接起来。大多数模型 API 都支持流式返回,你拿到的是一个 token 流,需要做的是:

  1. 逐块接收 token
  2. 判断当前块是普通文本还是工具调用
  3. 普通文本直接推给用户,工具调用先缓冲
  4. 工具调用完整后,执行工具,把结果作为新一轮输入

这里有个容易忽略的点:流式输出和工具调用是冲突的。因为工具调用需要完整的参数才能执行,你不能收到一半就去调工具。所以我的做法是,在流式过程中检测到工具调用开始时,先暂停向用户推送,等工具调用参数完整后再决定怎么展示。通常我会给用户一个"正在调用 XX 工具"的提示,让用户知道 Agent 在干活。

4.2 异步不是可选项,是必选项

Agent 天然是异步的。模型调用是 IO 密集的,工具调用也是 IO 密集的,如果全用同步阻塞的方式写,一个 Agent 同时只能服务一个用户,效率低得可怕。

我早期写的一个 Agent 就是纯同步的,单用户测试没问题,一上并发就崩。后来改成异步,用asyncio把模型调用和工具调用都做成协程,并发能力立刻上来了。这里的关键是把阻塞操作全部异步化,包括模型 API 调用、工具里的网络请求、文件读写。

async def run_agent(user_input, state): state.conversation.append({"role": "user", "content": user_input}) for _ in range(MAX_ITERATIONS): response = await model_client.call_async(state.conversation) if response.is_final: return response.content # 工具调用可以并发执行 results = await asyncio.gather(*[ execute_tool_async(call) for call in response.tool_calls ]) state.conversation.extend(format_tool_results(results)) return "达到最大迭代次数"

注意asyncio.gather那一段。如果模型一次返回了多个独立的工具调用,它们之间没有依赖关系,完全可以并发执行。这跟客户端里并发发起多个网络请求是一个思路。但要注意,有依赖关系的工具调用不能并发,比如第二个工具的参数依赖第一个工具的结果,那就得串行。判断依赖关系这件事,我通常让模型在返回工具调用时显式标注,或者干脆保守一点,默认串行,只在明确无依赖时才并发。

4.3 取消与超时:用户不想等了怎么办

客户端里用户点了个按钮,等了两秒没反应,会再点一次或者直接关掉。Agent 也一样,用户可能中途想取消,或者任务跑太久需要超时。

取消机制在异步 Agent 里要靠CancelledError和任务取消来实现。当用户点取消,你取消掉当前正在跑的协程,同时要确保清理工作做到位:正在执行的工具要能中断,已经产生的副作用要记录,状态要保存。这里最麻烦的是工具的中断,如果工具是一个不可中断的阻塞调用,取消就没那么干净。我的经验是,尽量把工具写成可中断的,或者给工具加一个较短的超时,避免长时间卡住。

超时则是另一层保护。我会给整个 Agent 循环设一个总超时,给单次模型调用设一个超时,给单个工具调用也设一个超时。三层超时叠加,任何一层超了都有对应的处理:单次模型超时就重试,单次工具超时就返回错误让模型决定怎么办,总超时就返回当前进度并提示用户。

5. 工具调用:Agent 的"系统 API"

5.1 工具定义就是接口设计

做过客户端开发的人对"接口设计"都不陌生。你定义一个 API,要考虑参数怎么传、返回值什么格式、错误怎么表达、版本怎么兼容。Agent 的工具定义,本质上就是给模型设计一套 API。

我见过很多工具定义写得非常随意,参数用自然语言描述,返回值是一大段文本。这样模型用起来全靠猜,成功率自然低。好的工具定义应该像好的 API 文档一样:参数名清晰、类型明确、描述精准、有示例。

{ "name": "search_orders", "description": "根据用户ID查询订单列表,返回最近30天的订单", "parameters": { "type": "object", "properties": { "user_id": { "type": "string", "description": "用户唯一标识,格式为 U 开头的 8 位字符串" }, "status": { "type": "string", "enum": ["pending", "shipped", "completed"], "description": "订单状态筛选,不传则返回全部" } }, "required": ["user_id"] } }

注意enum和required这些约束。它们能大幅降低模型传错参数的概率。这跟客户端里用类型系统约束接口是一个道理——能在定义层面约束的,就不要留给运行时去猜。

5.2 工具执行要有"沙箱"思维

客户端调用系统 API 要小心权限,Agent 执行工具也要小心副作用。一个能删数据的工具,如果模型误调了,后果可能很严重。

我的做法是给工具分级:只读工具随便调,写入工具要确认,危险工具要二次确认甚至人工审批。这个分级可以在工具定义里标注,执行时根据级别走不同的流程。

TOOL_RISK = { "search_orders": "read", "update_address": "write", "cancel_order": "dangerous", "delete_account": "dangerous", } def execute_with_guard(tool_name, args, state): risk = TOOL_RISK.get(tool_name, "dangerous") if risk == "read": return tools[tool_name](**args) if risk == "write": if not state.user_confirmed_write: return "需要用户确认后才能执行写入操作" return tools[tool_name](**args) if risk == "dangerous": return request_human_approval(tool_name, args)

这套机制在客户端里叫"权限控制",在 Agent 里同样适用。别指望模型永远不犯错,要在架构层面把犯错的代价控制住。

5.3 工具返回结果要"模型友好"

工具返回的结果是给模型看的,不是给人看的。这一点很多人会忽略。一个工具返回一大坨 JSON,模型解析起来费劲,还容易看漏关键信息。

我的经验是,工具返回结果要做面向模型的格式化:去掉冗余字段,把关键信息前置,用模型容易理解的结构。比如一个查询订单的工具,返回结果可以是这样:

订单查询结果: - 订单号:12345,状态:已发货,金额:299元 - 订单号:12346,状态:待付款,金额:89元 共 2 条记录

而不是一大段嵌套 JSON。这样模型一眼就能抓住重点,后续推理也更准。这跟客户端里"给用户展示的数据要经过格式化"是一个思路,只不过这里的"用户"是模型。

6. 错误处理与可观测性:让 Agent 可调试

6.1 错误分类:哪些能重试,哪些不能

客户端开发里,错误处理的第一课是区分错误类型。网络超时可以重试,参数错误重试也没用,权限错误要引导用户去授权。Agent 的错误处理也是这个逻辑。

我把 Agent 的错误分成几类:

错误类型例子处理策略
瞬时错误模型超时、网络抖动指数退避重试
格式错误模型返回非法 JSON让模型重新生成或走修复逻辑
业务错误工具返回"订单不存在"把错误信息喂回模型,让它决定
系统错误工具服务不可用降级或提示用户稍后重试
致命错误上下文超限、配额耗尽中断并明确告知用户

关键区别在于:瞬时错误重试,业务错误交给模型,致命错误直接中断。我见过有人把所有错误都无脑重试,结果一个"订单不存在"的错误重试了三次,白白浪费时间和 token。也见过有人一遇到错误就中断,其实很多错误模型自己就能处理。

6.2 日志与追踪:Agent 的"黑匣子"

Agent 最让人头疼的一点是"不可解释"。用户问为什么 Agent 给了这个答案,你很难说清楚。这时候就需要可观测性。

我的做法是给每一轮循环打详细的日志:输入是什么、模型返回了什么、调用了哪些工具、工具返回了什么、最终输出是什么。这些日志按会话 ID 串起来,出问题时可以完整回放整个决策过程。

def log_step(session_id, step_type, content): logger.info({ "session_id": session_id, "timestamp": time.time(), "type": step_type, # "input" / "model_output" / "tool_call" / "tool_result" "content": content, })

有了这些日志,调试 Agent 就从"猜"变成了"看"。用户反馈某个问题,我直接拉出对应会话的日志,一眼就能看出是哪一步出了问题:是模型理解错了,还是工具返回错了,还是上下文丢了信息。

这跟客户端里的崩溃日志、埋点是一个思路。没有可观测性的系统,等于没有调试能力。Agent 尤其如此,因为它的行为是概率性的,不记录过程根本没法复现问题。

6.3 给 Agent 加"健康检查"

客户端上线前要做各种测试,Agent 上线前也应该有一套"健康检查"。我通常会准备一组固定的测试用例,覆盖常见场景和边界情况,每次改动后跑一遍,看看通过率有没有下降。

这些用例包括:正常的多轮对话、工具调用成功、工具调用失败、上下文超限、模型返回格式错误、用户中途取消等等。跑的时候记录每个用例的耗时、token 消耗、是否通过。这样不仅能发现回归问题,还能监控性能变化。

我还会在生产环境加一些实时指标:平均响应时间、工具调用成功率、平均迭代次数、token 消耗分布。这些指标一旦异常,往往意味着某个环节出了问题。比如工具调用成功率突然下降,可能是某个外部服务挂了;平均迭代次数上升,可能是模型变笨了或者 prompt 需要调整。

7. 我踩过的几个典型坑和应对心得

7.1 上下文无限增长导致"失忆"

前面提过,但值得再强调。我最早做的 Agent 没有上下文管理,聊到三四十轮就开始胡言乱语。后来加了摘要机制,但摘要生成本身也要调模型,又引入了新的延迟和成本。

我的最终方案是分层摘要 + 关键信息提取。不是简单地把对话压缩成一段话,而是分两步:先用规则提取结构化信息(订单号、日期、金额这些),再用模型生成一段自然语言摘要。结构化信息精确可靠,自然语言摘要提供语境。两者结合,既省 token 又不丢关键信息。

7.2 工具调用参数格式错误

模型返回的工具调用参数,格式错误是家常便饭。有时候是 JSON 不合法,有时候是字段名写错,有时候是类型不对。我一开始的做法是直接报错,结果用户体验很差。

后来我加了一层参数修复:拿到模型返回的参数后,先做一轮校验和修复。JSON 不合法就尝试用宽松解析器解析,字段名错了就做模糊匹配,类型不对就尝试转换。修复不了的,再把错误信息喂回模型让它重新生成。这一层修复把工具调用的成功率从大概七成提到了九成以上。

7.3 流式输出和工具调用的时序问题

流式输出时,模型可能先输出一段文本,然后开始工具调用。如果处理不好,用户会看到文本输出到一半突然卡住,然后过一会儿又蹦出工具调用的结果,体验很割裂。

我的处理方式是缓冲 + 状态提示。检测到工具调用开始时,先把已经输出的文本缓冲住,给用户一个"正在处理"的提示,等工具执行完、模型继续生成时,再把缓冲的文本和后续内容一起平滑输出。这样用户看到的是一个连贯的过程,而不是断断续续的片段。

7.4 并发下的状态污染

这个坑比较隐蔽。我用异步写 Agent 后,多个会话共享了一些全局状态,结果出现了串话:A 用户的对话里出现了 B 用户的信息。排查了半天才发现是状态对象被复用了。

教训是:每个会话必须有独立的状态对象,全局状态只放只读的配置。所有可变的会话数据,都要绑定到会话 ID 上,通过上下文传递,绝不放在全局变量里。这跟客户端里"每个页面有独立的 ViewModel"是一个道理。

8. 把客户端思维用到底:一些进阶思路

8.1 组件化:把 Agent 拆成可复用的模块

客户端开发讲究组件化,Agent 也可以。我把 Agent 拆成几个可复用的组件:对话管理器负责上下文和状态,模型客户端负责模型调用和重试,工具注册中心负责工具的注册和查找,执行引擎负责决策循环,输出适配器负责把结果推给不同的前端。

这样拆的好处是,换一个前端(从命令行换成 Web 界面),只需要换输出适配器;换一个模型,只需要换模型客户端;加一个新工具,只需要往注册中心注册。每个组件职责单一,测试和替换都容易。

8.2 插件化:让工具可以动态加载

客户端的插件系统,Agent 也能借鉴。我把工具做成插件,每个插件是一个独立的模块,声明自己的名称、参数、风险级别和执行函数。启动时扫描插件目录,动态加载。这样加工具不用改主程序,部署也灵活。

# plugins/weather.py def register(): return { "name": "get_weather", "description": "查询指定城市的天气", "parameters": {...}, "risk": "read", "handler": get_weather_impl, }

主程序启动时遍历plugins目录,调用每个模块的register(),把返回的工具定义收集起来。这套机制在客户端里叫"插件架构",在 Agent 里同样好用。

8.3 版本化:Prompt 和工具都要能回滚

客户端发版要能回滚,Agent 的 prompt 和工具定义也应该版本化。我见过 prompt 改了一句话,整个 Agent 行为大变的情况。没有版本管理,出了问题都不知道回滚到哪。

我的做法是把 prompt 和工具定义都放在版本控制里,每次改动记录变更原因和测试结果。上线新版本前先跑一遍健康检查,通过率不达标就不上。这样即使出了问题,也能快速定位到是哪次改动导致的,回滚也有据可依。

9. 一些实操建议和收尾

如果你正准备开始一个 Agent 项目,我的建议是:先别急着调模型,先把客户端那套工程架子搭起来。状态管理、异步框架、错误处理、日志追踪,这些基础设施先到位,再去接模型。你会发现,当这些基础设施就绪后,接模型反而成了最简单的一步。

如果你已经在做 Agent 但总觉得哪里不对劲,不妨对照客户端开发的清单自查一遍:状态是不是清晰分离的?异步是不是彻底的?错误是不是分类处理的?日志是不是完整的?工具定义是不是像 API 一样严谨的?大概率你能找到几个可以立刻改进的点。

我个人在实际操作中的体会是,Agent 开发最难的部分从来不是"让模型变聪明",而是"让系统变可靠"。模型的能力你控制不了,但系统的可靠性你完全可以控制。把 Agent 当成一个客户端来写,用你写客户端时积累的所有工程经验去打磨它,它就会从一个"时灵时不灵的玩具"变成一个"真正能用的产品"。

最后分享一个小技巧:每次你觉得 Agent 行为诡异的时候,先别怀疑模型,去看看你的状态和上下文。十有八九,问题出在状态管理上,而不是模型能力上。这个判断在我过去大半年的实践里,准确率高得惊人。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询