做 AI 应用开发快三年我最大的一个感触是从“能聊天的 Demo”到“能干活的应用”中间隔着一整个 Agent 平台的距离。不少朋友拿着大模型 API 写了个对话页面跑通流式输出之后就觉得自己已经入门了 AI Agent。其实那只是起点。这篇文章是我自己项目记录的开篇聊一聊我怎么把一个单纯的大模型对话 Demo一步步重构成一个可持续演进的 Agent 平台。内容会覆盖 Demo 阶段的快速实现、Agent 抽象、工具调用、记忆管理以及我自己踩过的坑。适合刚接触 Agent 开发、正纠结于“Demo 之后下一步做什么”的开发者。1. 项目定位从 Demo 到平台到底差在哪1.1 先说结论Agent 平台不是在聊天框上叠功能一个典型的 AI 对话 Demo通常等于三样东西模型 API、消息列表、输入框。前端把用户的问题拼成 messages 数组后端调大模型接口拿到流式返回后逐字渲染到页面上。这个流程我最早只花一个下午就写完了第一次看到流式输出在屏幕上滚动的时候确实有点兴奋。但兴奋期很短。一旦你开始认真思考“让 AI 帮我把今天的销售报表汇总一下并且把异常数据标红发给我”原来的聊天 Demo 就完全不够用。因为它只能回答“你怎么想”不能执行“你怎么做”。真正的 Agent 平台不是在聊天框上叠按钮、换人设、加几个 prompt 模板而是要把大模型的“语言能力”接入到真实的业务流程里。我后来总结过一句话Demo 验证的是模型的想象力平台考验的是工程的基本功。这句话基本概括了我整个项目的演进方向。1.2 你会在 0.5 个需求之后遇到的三块短板第一个需求可能只是“帮我写个周报”这时候 Demo 还能扛。但第二个需求如果是“顺便把周报里提到的项目进度同步到飞书文档”Demo 直接崩。这个崩不是报错而是你会发现模型生成的文字里根本没有真实数据它是凭空“编”的。这里有三块短板几乎每个从 Demo 走向 Agent 平台的人都会撞上记忆短板Demo 用 messages 数组保存上下文token 一长就爆。平台需要区分短期记忆和长期记忆该压缩的压缩该检索的检索。工具短板Demo 只会输出纯文本它不会打开网页、查数据库、发请求。平台需要把模型的意图变成真实的函数调用再让模型根据结果继续推理。演进短板Demo 里加一个新功能要改核心代码每加一个角色就复制一份 prompt改一次逻辑就担心其他地方被带崩。平台需要组件化、可配置化让新技能像装插件一样加进去。如果你知道自己迟早要接真实数据源那么第一版 demo 就不该只做一个聊天框至少要预留“工具调用”的接口位。2. 第一版 Demo如何快速搭一个能流式对话的接口2.1 模型接入我为什么先选 OpenAI 兼容接口第一版 Demo 的核心目的是验证大模型能不能理解业务问题所以我不想在模型接入上浪费太多时间。当时我直接选了 OpenAI 兼容接口不管后端是官方 API、国内云厂商的推理服务还是本地用 vLLM 或 Ollama 起的模型只要能支持/v1/chat/completions协议我的代码就可以不动。配置也很简单import openai client openai.OpenAI( base_urlhttp://localhost:8000/v1, # 本地模型服务或统一网关 api_keydummy # 自定义网关一般需要任意非空字符串 )这样做的最大好处是“切换成本低”。Demo 阶段我会用托管模型快速看效果后面为了数据安全切换到私有化部署只改一个 base_url 就行。很多教程会直接把 api_key 写死在代码里我建议至少放到环境变量里不然第一个朋友看到你代码都会提醒你“这玩意儿不能上公网”。2.2 流式输出让用户先看到字比什么都重要对话 Demo 如果不做流式输出体验会差很多。一个几十字的回答要等好几秒才一次性冒出来用户早走了。流式返回看着只是技术细节实际上是用户等待过程中最直接的反馈。我用 FastAPI 写了一个简单的 SSE 流式接口from fastapi import FastAPI from fastapi.responses import StreamingResponse import openai app FastAPI() def stream_chat(messages): resp client.chat.completions.create( modelgpt-4o-mini, messagesmessages, streamTrue ) for chunk in resp: if chunk.choices[0].delta.content: yield chunk.choices[0].delta.content app.post(/chat) async def chat(payload: dict): messages payload[messages] return StreamingResponse(stream_chat(messages), media_typetext/plain)前端用 EventSource 或者 fetch 流式读取/chat的返回内容按行渲染文本就能获得“边生成边出现”的效果。这个 Demo 足以支撑多轮对话但它把所有的逻辑都堆在 messages 里用户问什么、模型回答什么、历史记录怎么存全在这一坨数组里。它解决的问题只有一个证明“大模型能接进来能聊”。2.3 对话 Demo 的隐藏缺陷模型只会说不会做我在 Demo 跑通以后立刻做了一个测试问模型“帮我查北京明天天气如果下雨就提醒我带伞”。模型回答得很漂亮但仔细一看它完全是在“编气象信息”。这不是模型能力差而是它当时没有获取外部信息的通道。这个问题不是加一个 prompt 就能解决的你需要给模型一种“使用工具”的能力。在 OpenAI 协议里这对应着tools参数和tool_calls返回结构。也就是让模型在需要时输出一个“我想调用一个函数”的结构化结果而不是直接输出自然语言。我从这一刻开始意识到真正的 Agent 平台第一件事不是把聊天框做得多漂亮而是把“模型输出意图”和“系统执行动作”解耦开。这也是接下来所有重构工作的核心。3. 向 Agent 平台演进的核心设计3.1 用“目标循环”替代“一问一答”第一版重构我把 Agent 定义成一个非常简单的接口给它一个任务它自己决定调哪些工具、做几步推理、最终返回一个结果。这个过程不是一次问答而是一个循环。核心循环长这样class Agent: def __init__(self, llm, tools, max_steps5): self.llm llm self.tools tools self.max_steps max_steps async def run(self, task: str, context: dict): messages self.build_initial_messages(task, context) for step in range(self.max_steps): resp await self.llm.chat(messages, toolsself.tools) if resp.tool_calls: messages.append(resp.to_message()) # 记录模型决策 for call in resp.tool_calls: result await self.execute_tool(call) messages.append(self.tool_result_message(call, result)) else: return AgentResult(outputresp.content, stepsstep 1) raise AgentLoopLimitExceeded()这里有个很容易犯的错误第一次做 Agent 循环很多人会把 tool 调用结果直接拼到原来的 messages 后面甚至覆盖掉中间的推理过程。我建议保留完整的决策轨迹因为模型下一步会需要看到“之前调了什么工具、工具返回了什么、现在应该继续做什么”。删掉任何一环后面都可能断链。把 Agent 抽象成目标循环后就不再是简单的一问一答了。它会像一个员工一样先听懂任务再决定查哪些资料最后汇总输出。这个循环本身是所有 Agent 平台的骨架。3.2 工具注册把模型输出的意图变成真实动作工具调用是 Agent 平台最关键的一环。模型本身不会执行任何外部动作它只会输出一个结构化的“动作请求”。平台要做的是接收这个请求、验证参数、执行对应函数、再把执行结果回传给模型。以天气查询为例工具 Schema 长这样tools [ { type: function, function: { name: get_weather, description: 查询指定城市的实时天气预报。, parameters: { type: object, properties: { city: { type: string, description: 城市名比如北京、上海 }, date: { type: string, description: 查询的日期格式 YYYY-MM-DD默认今天 } }, required: [city] } } } ]注意模型不会直接“调用”函数而是返回类似get_weather(city北京)的结构。平台解析后需要自己去执行真实逻辑再把结果包装成文本或者结构化数据回填给模型。我踩过的坑是工具描述写得不够具体。比如只写“查询天气”模型经常不传城市名我不得不在代码里做兜底。后来我把 description 改成“查询指定城市的实时天气预报城市必须是中文名称”情况立刻好转。工具描述本质上是模型的操作手册写清楚边界、类型、默认值比什么都重要。工具注册机制建议做成“注册表 装饰器”TOOL_REGISTRY {} def register_tool(schema): def decorator(func): func.schema schema TOOL_REGISTRY[func.__name__] func return func return decorator register_tool(tools[0]) def get_weather(city: str, date: str ): # 真实实现调用天气服务 if city 北京: return {city: city, weather: 晴, temperature: 18-26℃} return {city: city, weather: 未知, temperature: 未知}这样每加一个新工具只要写一个函数、配一个 Schema、加一个装饰器就行Agent 循环里的调度逻辑完全不用动。我的工具从 3 个增加到 30 个核心代码几乎没改。3.3 记忆从“塞进窗口”到“分层管理”对话 Demo 里最省事的做法是把所有历史消息都塞给模型直到超过上下文窗口报错。在 Agent 平台里一定不能这么干因为工具的中间结果、系统提示词、角色设定已经占了不少 token历史对话再多一膨胀连正常推理都做不到。我采用的策略很简单分两层短期记忆保留最近 5 轮对话以及当前任务过程中产生的所有工具调用记录。长期记忆把更早的对话内容用模型做一个摘要摘要转成 embedding 存入向量库需要时通过检索召回。短期记忆实现起来最直接保留最近 messages 就行。长期记忆我第一版没有用向量库只用了“滚动摘要”当消息数超过阈值时让模型把当前对话总结成一段话存下来之后清空旧消息保留摘要。def compress_history(messages): if len(messages) 20: summary_prompt 请用不超过200字概括以上对话的关键信息\n format_messages(messages[:-10]) summary llm.chat([{role: user, content: summary_prompt}]) return [{role: system, content: f历史摘要{summary}}] messages[-10:] return messages这个做法虽然简陋但效果稳定。它告诉我们一个原则记忆的价值不是“存得越多越好”而是“在需要的时候能拿到对的上下文”。3.4 可演进性给平台留出“换零件”的接口演进性是平台和 Demo 最重要的区别。我会从第一天起就考虑如果明天要换一个更便宜的大模型如果后天要给某个用户开启新的工具组如果外部服务的返回格式变了平台能不能尽量少改代码答案是用接口约束实现而不是把具体逻辑写死在循环里。我在工程里做了三件事模型层独立LLM 调用通过一个ChatModel接口暴露后续换模型只改工厂函数。工具层注册化每个工具是独立函数 Schema工具权限通过配置文件控制开关。可观测埋点Agent 每一步循环都记录 trace_id、输入输出、耗时、token 消耗方便后面对比版本。这样做的收益是我可以快速验证“换一个更强的模型能不能解决某个失败用例”而不是每次都花半天改耦合代码。可演进性不是一开始就设计得多完美而是每次改动都下意识地问自己如果以后再遇到类似需求这个位置能不能少动刀4. 实操记录一个最小可用 Agent 平台的样子4.1 工程结构怎么摆我第一版重构时的目录结构非常简单够用就好agent-platform/ ├── agent/ │ ├── core.py # Agent 循环 │ ├── tools.py # 工具注册表与具体实现 │ └── memory.py # 记忆管理滚动摘要 ├── server/ │ ├── main.py # FastAPI 服务 │ └── sse.py # 流式返回封装 ├── eval/ │ └── cases.py # 回归用例 └── config.yaml # 模型名、工具开关、步数限制这个结构的好处是把“Agent 核心逻辑”和“外部协议”分开。核心模块不依赖 FastAPI即使未来要接消息队列、WebSocket、微信群机器人改动也只会发生在 server 层。我见过很多项目把 FastAPI 的 Request 对象传进 Agent 里最后耦合到不敢重构这是最要命的。config.yaml 里我通常会放这些字段model: provider: openai-compatible base_url: http://localhost:8000/v1 name: qwen2.5-14b-instruct agent: max_steps: 8 default_tools: [get_weather, get_time, search_docs] memory: max_messages: 20 use_summary: true把“哪些工具默认开启”“最大步数”这类参数放到配置里是为了让非开发人员也能安全调整实验参数不用碰代码。4.2 核心代码Agent 循环与工具调度下面是我精简后的核心代码重点看异常处理和失败反馈。# agent/tools.py TOOL_REGISTRY {} def register_tool(schema): def decorator(func): func.schema schema TOOL_REGISTRY[func.__name__] func return func return decorator register_tool({ type: function, function: { name: get_weather, description: 查询指定城市的实时天气。城市名使用中文。, parameters: { type: object, properties: { city: {type: string, description: 城市名比如北京、上海} }, required: [city] } } }) def get_weather(city: str): # 第一版先用假数据验证链路 mock { 北京: 晴18-26℃, 上海: 小雨20-25℃, 广州: 多云22-30℃ } return {city: city, weather: mock.get(city, 未知)}# agent/core.py class Agent: def __init__(self, llm, tools, max_steps8): self.llm llm self.tools tools self.max_steps max_steps async def execute_tool(self, call): func self.tools.get(call.function.name) if not func: return {error: f工具 {call.function.name} 不存在} try: args json.loads(call.function.arguments or {}) result await func(**args) return result except Exception as e: # 关键把错误反馈给模型让模型自己调整 return {error: f工具执行失败: {str(e)}} async def run(self, task: str, context: dict None): messages [{role: system, content: self.system_prompt}, {role: user, content: task}] for step in range(self.max_steps): resp await self.llm.chat( messages, tools[func.schema for func in self.tools.values()] ) if not resp.tool_calls: return AgentResult(outputresp.content, stepsstep 1) messages.append(resp.message) for call in resp.tool_calls: result await self.execute_tool(call) messages.append({ role: tool, tool_call_id: call.id, content: json.dumps(result, ensure_asciiFalse) }) return AgentResult(output达到最大步数任务未完成。, stepsself.max_steps)这里最关键的一行是except Exception返回错误信息。模型如果调用一个不存在的工具、或者参数传错平台不应该直接崩溃而应该把“执行失败”的结果喂回给模型让它决定是换个参数重试还是换一个工具还是直接告诉用户无能为力。这比写一堆 if-else 判断模型输出来得稳定得多。4.3 跑一个任务观察模型怎么用工具我搭好最小工程后跑了一个测试任务用户消息北京明天会下雨吗如果下帮我提醒我带伞。日志里可以看到 Agent 走了两步模型第一次返回了一个 tool_callget_weather(city北京)平台执行 get_weather返回{city: 北京, weather: 晴18-26℃}模型看到结果后输出了最终回答北京明天是晴天没有雨不需要带伞。这个例子看起来简单但它是整个平台能够成立的基石。模型没有直接从自己的“记忆”里编天气而是先调用工具再用工具返回的真实数据作答。我特意记录了一次运行的观测数据这个任务一共 2 次 LLM 请求输入 token 约 300输出 token 约 150总耗时 1.8 秒本地小模型。如果任务复杂到连续调用 4-5 个工具token 会成倍上涨耗时也会明显变长。所以在后续优化里我开始刻意关注“少调工具尽量直接回答”的模型策略但要注意别为了省 token 牺牲正确性。4.4 怎么快速暴露成可访问的服务有了 Agent 核心暴露一个 HTTP 接口很简单。我用 FastAPI 写了一个最基础的POST /agent/runapp.post(/agent/run) async def agent_run(payload: dict): task payload[task] context payload.get(context, {}) result await agent_instance.run(task, context) return {output: result.output, steps: result.steps}如果是长任务比如“分析这个文档并给出20条改进建议”建议用 WebSocket 或消息队列做异步任务而不是让用户一直等在一个 HTTP 请求上。我第一次上线时直接让 HTTP 请求跑完整个 Agent 循环结果 DB 查询工具慢一点就把请求超时搞到 60 秒体验非常差。后来改成“接口只提交任务返回 task_id前端轮询结果”的异步模式才算是真正能用了。容器化部署的时候有几个点需要特别留意模型服务的内存和显存占用要单独设限额Agent 进程要配置最大运行时间工具调用外部 API 时要设置独立的超时时间。只要有一个外部接口卡住就可能拖死整个 Agent 服务。5. 避坑指南我踩过的 7 个 Agent 平台开发问题5.1 常见问题速查表我把这个项目里遇到过的问题整理成了一张速查表方便你排查时直接对号入座问题常见原因解决思路上下文超限messages 无限增长工具记录太多滚动摘要 只保留最近几轮工具调用死循环同一个工具反复调用同一参数限制最大步数并且检测重复 tool_call工具参数幻觉工具描述不清晰模型猜测参数写清楚 description、类型、默认值模型返回非法 JSON某些模型 function calling 不稳定用严格 JSON Schema 校验失败后重试一次并发请求互相干扰Agent 实例内部共享了可变状态每个请求创建独立 Agent 实例外部服务挂了拖垮平台工具调用没有超时控制给每个工具执行包一层超时限制改动后无法回归没有评估集维护一份 cases.py每次改动都跑一遍这些问题的坑我都踩过挑一个最典型的展开说说。5.2 一个让我改了两版的死循环排查过程有一次 Agent 在测试过程中突然不返回了日志显示模型反复调用同一个“数据库搜索”工具参数还一模一样。一开始我以为是模型出 bug 了后来加了日志发现工具执行返回的结果是一个空列表但那一次调用本身没抛异常。模型拿到空结果以后以为“还没查到”于是又调了一次同样的搜索。无限循环下去直到 max_steps 上限任务失败。第一版修复很简单我在工具返回结果里加了query_id并且要求模型看到相同 query_id 时不要重复调用。但这样只是在打补丁问题根源是“模型没有收到足够明确的反馈这次搜索已经完成只是结果为空”。第二版我改了 execute_tool 的包装逻辑如果工具返回为空除了原始结果还会附加一句提示这是最终结果请不要重复调用相同工具。如果需要换个条件请修改参数。从那以后这类死循环基本绝迹了。这个案例给到我的启示是Agent 平台里给模型的反馈质量比模型本身的能力更影响整体表现。工具执行失败和空结果要区分错误信息要具体否则模型就是在盲猜。5.3 给新手的三个练手方向如果你也想从 Demo 走向 Agent 平台我建议从这三个方向开始一个工具做出闭环选一个简单工具比如查 IP、查时间、算今日剩余天数把“模型生成 tool_call - 执行 - 回填 - 模型总结”整个闭环跑通比一次性接十个工具有用得多。先不要碰多智能体多智能体只是编排层的高级玩法单 Agent 多工具的架构已经能解决 80% 的真实需求。多智能体带来的状态同步、消息路由、死锁问题会让新手直接劝退。从第一版就加 trace_id每一条请求、每一次工具调用、每一步模型返回都要打印出带 trace_id 的日志。没有日志的时候排查问题全靠猜有日志以后问题基本都能在一个小时定位完。6. 写在开篇之后下一步我会怎么做这篇文章算是我这个项目系列的开篇核心是把“用一个 Demo 接大模型”升级成“一个能让模型真正干活的 Agent 平台骨架”。我自己在后续规划里会往三个方向继续推进一是给长期记忆接入真正的向量库让 Agent 能跨会话记住用户偏好二是增加任务调度能力把被动问答变成主动执行比如定时生成日报、监控异常三是把评估集补完整每次修改模型或工具都先跑一遍回归用例不然我根本不敢升级。最后再分享一点个人体会。很多人看到 Agent 的表现会下意识把它归功于“大模型很聪明”但我在实际搭建过程中发现真正让平台稳定的是工程上的细节工具描述写不写清楚、错误返回给不给模型、上下文怎么管理、日志是否完整。Demo 阶段你可以靠模型临场发挥平台阶段必须靠系统设计来兜底。别小看这个小小的目标循环当你把第一个工具接进去并看到模型为了完成真实任务主动调用它的时候你会明白这是完全不同于“聊天机器人”的一层体验。下一篇我会专门讲工具调用的工程落地包括参数校验、并发控制、外部 API 授权还有怎么设计一套能复用的工具 Schema。如果你正在从零搭自己的 Agent 平台建议先按这篇文章的骨架把最小循环跑起来后续再慢慢往里面填进阶能力。