图解AI应用架构设计:从LLM调用到Agent编排的六层工程实践
1. 为什么“图解”才是AI应用架构的正确打开方式
做AI应用开发这些年,我最大的感受是:架构图不是画给老板看的,是画给自己保命的。一个LLM应用从Demo到上线,中间隔着的不是代码量,而是对架构层次的清晰认知。很多人一上来就写Prompt、调API,结果做到第三周发现:上下文管理混乱、Agent状态丢失、工具调用超时无法重试、多轮对话的Token成本失控。这些问题归根结底,都是因为脑子里没有一张完整的架构图。
所谓“图解AI应用架构设计”,核心就是把一个AI应用拆解成可独立演进、可单独替换、可分别观测的若干层。它解决的不是“怎么调通一个模型”的问题,而是“怎么让一个AI应用在真实流量下稳定跑三个月”的问题。这篇文章适合三类人:一是刚接触LLM应用开发、想建立系统认知的工程师;二是已经在做Agent项目、但架构越写越乱的开发者;三是需要向团队解释AI系统设计思路的技术负责人。
我会用图解的思路,把AI应用架构从底到顶拆成六层,每一层讲清楚它为什么存在、核心组件是什么、实际落地时怎么选型、踩过哪些坑。关键词会自然融入:AI应用、架构设计、Agent、LLM、MCP,以及当下热门的Agent开发、大模型应用、工具编排等话题。全文基于我实际做过的几个Agent项目的经验,不是纸上谈兵。
2. AI应用架构的整体分层设计思路
2.1 从“调API”到“建系统”的认知跃迁
大部分人接触LLM的第一行代码是这样的:
from openai import OpenAI client = OpenAI() response = client.chat.completions.create( model="gpt-4", messages=[{"role": "user", "content": "你好"}] ) print(response.choices[0].message.content)这段代码能跑通,但它不是一个AI应用,它只是一个API调用示例。真正的AI应用需要回答一系列工程问题:用户会话怎么管理?模型返回的工具调用请求怎么执行?执行失败了怎么重试?多个Agent之间怎么协作?上下文超长了怎么压缩?这些问题的答案,构成了架构设计的全部内容。
我习惯把AI应用架构分成六层,从下往上依次是:模型接入层、上下文与记忆层、能力扩展层(工具/MCP)、Agent编排层、应用服务层、可观测与安全层。这个分层不是教科书上的标准答案,而是我在实际项目中反复调整后觉得最顺手的一种切法。它的好处是每一层职责单一,替换成本低——比如你想从GPT-4换到Claude,只需要改模型接入层;想加一个新工具,只需要在能力扩展层注册。
2.2 分层架构的核心原则:依赖单向、接口稳定
分层设计最容易犯的错误是“跨层调用”。比如在Agent编排层直接写HTTP请求去调外部API,跳过了能力扩展层的统一封装。这样做短期省事,长期就是灾难:工具的超时策略、鉴权、日志、重试逻辑散落在各处,改一个地方要翻十个文件。
我的原则是:上层依赖下层,下层不知道上层的存在。Agent编排层只知道“我有一个工具叫search_flight”,它不关心这个工具背后是HTTP调用、MCP协议还是本地函数。能力扩展层负责把各种异构的工具统一成标准接口。模型接入层负责把不同厂商的API差异抹平。这样每一层都可以独立测试、独立替换。
另一个原则是接口稳定优先于实现优雅。工具调用的输入输出格式一旦定下来,就不要轻易改。我见过一个项目因为中途把工具返回格式从JSON改成纯文本,导致所有Agent的Prompt都要重写。接口稳定意味着你可以放心地在上面叠加新功能,而不是每次改动都引发连锁反应。
2.3 一张图看懂六层架构的职责边界
| 层级 | 核心职责 | 典型组件 | 替换频率 |
|---|---|---|---|
| 模型接入层 | 统一多厂商LLM调用 | API适配器、路由、降级 | 低 |
| 上下文与记忆层 | 会话管理、上下文压缩、长期记忆 | 对话历史、向量库、摘要器 | 中 |
| 能力扩展层 | 工具注册、MCP协议、执行沙箱 | 工具注册表、MCP Client | 高 |
| Agent编排层 | 任务规划、多Agent协作、状态机 | 编排引擎、Agent运行时 | 中 |
| 应用服务层 | 业务逻辑、API网关、鉴权 | Web框架、消息队列 | 低 |
| 可观测与安全层 | 日志、追踪、成本、内容安全 | Tracing、Guardrail | 持续 |
这张表是我在多个项目里总结出来的,替换频率这一列特别值得注意。能力扩展层替换频率最高,因为业务需求变化最快,今天要查天气,明天要接内部工单系统。所以这一层一定要设计得足够灵活,注册一个工具的成本要低到“写一个函数加一个装饰器”的程度。
3. 模型接入层:LLM调用的统一抽象与容错
3.1 为什么不能直接调厂商SDK
直接调厂商SDK的问题在于锁定。你今天用某家的SDK写了两百行代码,明天老板说“换个模型试试效果”,你就得改两百行。更麻烦的是,不同厂商的API在参数命名、返回结构、错误码、流式输出格式上都有差异。比如有的用max_tokens,有的用max_output_tokens;有的工具调用返回tool_calls数组,有的返回function_call对象。
统一抽象层的做法是定义一个内部标准接口,所有厂商的适配器都实现这个接口。核心方法就三个:chat()、stream_chat()、embed()。输入是一个标准化的消息列表和参数对象,输出是一个标准化的响应对象。适配器负责把标准请求翻译成厂商格式,再把厂商响应翻译回标准格式。
class LLMAdapter: def chat(self, messages: list[Message], tools: list[Tool] = None, **kwargs) -> Response: raise NotImplementedError def stream_chat(self, messages: list[Message], **kwargs) -> Iterator[Chunk]: raise NotImplementedError这个抽象看起来简单,但它是整个架构稳定的基石。有了它,你可以在配置文件里切换模型,可以在运行时根据任务类型路由到不同模型,可以在某个厂商故障时自动降级到备用模型。
3.2 模型路由与降级的实际策略
模型路由不是简单地“哪个便宜用哪个”。我的策略是按任务复杂度分级:简单分类、意图识别用便宜的小模型;复杂推理、代码生成用大模型;需要长上下文的用支持大窗口的模型。路由决策可以基于规则,也可以基于一个轻量分类器。
降级策略更关键。我遇到过某厂商API在高峰期返回503的情况,如果没有降级,整个应用就挂了。降级链一般配两到三级:主模型失败后重试一次,仍失败则切到备用厂商的同级别模型,再失败则切到小模型并给用户提示“当前响应质量可能下降”。
注意:降级不是无脑切换。切换前要检查备用模型是否支持当前请求的特性,比如工具调用、JSON模式、视觉输入。我见过因为备用模型不支持工具调用导致Agent直接卡死的案例。
3.3 Token成本控制的第一道防线
成本控制要从接入层就开始做。第一,在适配器里统一记录每次调用的Token消耗,包括输入和输出,打上模型名、任务类型、用户ID的标签。第二,设置单次请求的Token上限,防止某个用户粘贴一篇长文导致成本爆炸。第三,对输出长度做限制,很多场景下模型会啰嗦地重复问题,设置max_tokens能省不少钱。
我实测过一个客服场景,不加输出限制时平均每次响应消耗800 Token,加了“回答不超过三句话”的约束后降到300 Token,成本直接砍掉六成。这些约束应该写在接入层的默认参数里,而不是指望每个调用方都记得加。
4. 上下文与记忆层:让LLM“记得住”的工程实现
4.1 对话历史管理的三种模式
上下文管理是AI应用最容易出问题的地方。最朴素的做法是把所有历史消息都塞进Prompt,但Token很快就不够了。我总结出三种模式,按场景选用:
全量模式:保留完整对话历史,适合短会话、客服工单这类轮次少的场景。实现最简单,但超过模型窗口就崩。
滑动窗口模式:只保留最近N轮对话,适合闲聊、问答。问题是会丢失早期的重要信息,用户说“我前面提到的那个订单”时模型一脸茫然。
摘要压缩模式:把早期对话用LLM总结成一段摘要,保留摘要加最近几轮原文。这是我最常用的模式,兼顾了信息保留和Token控制。摘要的触发时机可以按轮次(比如每10轮压缩一次)或按Token数(比如超过窗口70%时压缩)。
def manage_context(history, max_tokens=4000): if count_tokens(history) < max_tokens * 0.7: return history # 保留最近5轮,其余压缩成摘要 recent = history[-5:] older = history[:-5] summary = llm.summarize(older) return [SystemMessage(f"之前的对话摘要:{summary}")] + recent4.2 长期记忆:向量库不是万能药
长期记忆通常用向量库实现,把用户的历史信息、偏好、事实存成向量,需要时检索出来注入Prompt。但向量检索有个坑:相似不等于相关。用户问“我的订单到哪了”,向量检索可能召回“用户上次咨询过退货政策”,这两者在语义上相似但当前场景无关。
我的做法是混合检索:向量相似度加关键词匹配加时间衰减。时间衰减很重要,三个月前的偏好可能已经过时了。另外,长期记忆的写入要克制,不是什么信息都值得存。我一般只存三类:用户明确表达的偏好、跨会话需要保持的事实、以及从交互中提炼的用户画像。
提示:向量库的维度选择和嵌入模型要匹配。换嵌入模型意味着所有存量向量都要重新计算,这个成本要在项目初期就考虑进去。我一般选1536维或1024维,兼顾效果和存储成本。
4.3 上下文注入的顺序与优先级
Prompt里信息的排列顺序会影响模型的表现。我的经验顺序是:系统指令在最前,然后是长期记忆摘要,接着是当前任务相关的工具定义,再是对话历史,最后是用户当前输入。系统指令放最前是因为模型对开头的内容注意力更集中;用户输入放最后是因为它最接近生成位置,影响最直接。
工具定义的位置有讲究。如果工具很多,全部塞进去会占用大量Token且干扰模型判断。我的做法是先用一个轻量检索步骤,根据用户输入筛选出最相关的5到8个工具,只把这些工具的定义注入Prompt。这样既省Token又提高工具选择的准确率。
5. 能力扩展层:MCP与工具生态的工程化落地
5.1 MCP到底是什么,为什么它重要
MCP(Model Context Protocol)是这两年AI应用开发领域最值得关注的变化之一。简单说,它是一套标准协议,让LLM应用能以统一的方式发现和调用外部工具、数据源和服务。在MCP之前,每接一个工具就要写一套适配代码;有了MCP,工具提供方实现一个MCP Server,应用方用MCP Client连接,双方通过标准协议通信。
MCP的核心价值在于解耦。工具的实现和工具的调用方不需要知道彼此的技术栈。一个用Python写的数据库查询工具,和一个用TypeScript写的应用,可以通过MCP无缝协作。这对Agent开发尤其重要,因为Agent需要动态发现和组合工具,标准协议让这种动态性成为可能。
我实际用MCP接过几类工具:文件系统操作、数据库查询、浏览器自动化(比如Playwright MCP)、以及内部API。体验下来,MCP最大的好处是工具注册成本极低。以前接一个工具要写适配器、定义Schema、处理错误,现在只要配置一个MCP Server地址,工具列表自动发现。
5.2 工具注册表的设计与工具选择策略
不管用不用MCP,能力扩展层都需要一个工具注册表。注册表的核心功能是:注册工具、描述工具、执行工具、记录调用。工具的描述要足够清晰,因为LLM是根据描述来决定用哪个工具的。描述写得好,工具选择准确率能提升一大截。
工具选择策略我试过几种。最简单的是全量注入,工具少的时候没问题。工具多了之后,我用两阶段选择:先用一个分类模型或关键词匹配缩小候选范围,再让主模型从候选中选。还有一种做法是给工具打标签,按场景分组,根据当前对话的意图加载对应组的工具。
class ToolRegistry: def __init__(self): self.tools = {} def register(self, name, description, schema, handler): self.tools[name] = Tool(name, description, schema, handler) def get_relevant(self, query, top_k=8): # 基于描述做语义检索,返回最相关的工具 return semantic_search(query, self.tools.values(), top_k) def execute(self, name, args): tool = self.tools[name] return tool.handler(**args)5.3 工具执行的沙箱与超时控制
工具执行是AI应用里最容易出安全事故的环节。LLM生成的参数可能包含注入攻击、路径穿越、超大查询。我的做法是三层防护:第一层是参数校验,用JSON Schema严格校验类型和范围;第二层是执行沙箱,文件操作限制在指定目录,数据库查询限制为只读且加LIMIT;第三层是超时控制,每个工具调用设置硬超时,超时后返回错误让Agent决定下一步。
超时时间怎么定?我的经验是:查询类工具5到10秒,写入类工具15到30秒,浏览器自动化类30到60秒。超时后不要直接抛异常给用户,而是返回一个结构化的错误信息给Agent,让Agent有机会重试或换工具。我见过因为一个工具超时导致整个Agent崩溃的情况,就是因为没有做超时后的优雅降级。
注意:工具执行结果要截断。一个数据库查询可能返回几万行,全部塞回Prompt会直接撑爆上下文。我一般在工具层就做截断,比如最多返回50行或2000字符,并在结果里注明“已截断”。
6. Agent编排层:从单Agent到多Agent协作
6.1 Agent的核心循环:感知、规划、执行、反思
一个Agent的本质是一个循环:接收输入,规划下一步动作,执行动作,观察结果,再规划。这个循环听起来简单,但工程实现上有大量细节。规划环节,模型可能输出无效的动作格式;执行环节,工具可能失败;反思环节,模型可能陷入死循环。
我的做法是给循环加上明确的终止条件和最大步数。一般设置最大10到15步,超过就强制返回当前结果并提示“任务未完全完成”。终止条件包括:模型输出了最终答案、达到最大步数、连续两次动作相同(说明卡住了)、或者累计Token超过预算。
def agent_loop(task, max_steps=12): history = [SystemMessage(AGENT_SYSTEM_PROMPT), UserMessage(task)] for step in range(max_steps): response = llm.chat(history, tools=registry.get_all()) if response.is_final_answer: return response.content if response.has_tool_calls: for call in response.tool_calls: result = registry.execute(call.name, call.args) history.append(ToolResultMessage(call.id, result)) if is_stuck(history): break return "任务处理达到步数上限,请补充信息后重试"6.2 多Agent协作的三种拓扑结构
单Agent能力有限,复杂任务需要多Agent协作。我实践过三种拓扑:
主管- worker模式:一个主管Agent负责拆解任务和分配,多个worker Agent各司其职。适合任务可以清晰分解的场景,比如“调研报告”可以拆成搜索、分析、写作三个worker。
流水线模式:Agent按顺序处理,前一个的输出是后一个的输入。适合有明确阶段的任务,比如“数据清洗→分析→可视化→报告”。
辩论模式:多个Agent对同一问题给出方案,互相评审,最后综合。适合需要高质量决策的场景,但Token消耗大,一般只在关键决策点用。
选哪种拓扑取决于任务的可分解性和对质量的要求。我的经验是,大部分业务场景用主管-worker模式就够了,辩论模式成本太高,除非是特别重要的决策。
6.3 Agent状态管理与断点恢复
Agent执行过程中可能因为各种原因中断:工具超时、模型限流、用户关闭页面。如果没有状态管理,用户回来就得从头开始。我的做法是把Agent的每一步状态持久化到数据库或Redis,包括当前步数、历史消息、待执行的工具调用。
断点恢复的关键是幂等性。恢复时不能重复执行已经成功的工具调用,否则可能产生副作用(比如重复下单)。我在工具执行记录里标记状态:pending、executing、success、failed。恢复时只重新执行pending和failed的,success的直接用缓存结果。
提示:状态持久化的粒度要适中。每一步都存会增加IO开销,存太少又恢复不了。我一般每完成一个“规划-执行”循环存一次,工具调用内部的状态不单独存。
7. 可观测与安全层:AI应用上线的最后一道防线
7.1 追踪与日志:看见Agent的每一步
AI应用的可观测性和传统应用很不一样。传统应用看QPS、延迟、错误率就够了,AI应用还要看:每次调用的Token消耗、工具调用的成功率、Agent的步数分布、上下文长度变化、模型输出的质量指标。
我用OpenTelemetry做基础追踪,每个用户请求生成一个Trace,每个LLM调用和工具调用是一个Span。Span上记录模型名、Token数、延迟、是否命中缓存。这样出问题时可以快速定位是模型慢、工具慢还是编排逻辑有问题。
日志要记录完整的Prompt和响应,但要注意脱敏。用户输入里可能有手机号、身份证号,这些在落盘前要过滤。我一般用正则做基础脱敏,敏感字段用占位符替换。
7.2 内容安全与Guardrail的工程实现
内容安全是AI应用不能回避的问题。我的做法是在三个位置加Guardrail:输入侧、工具执行侧、输出侧。
输入侧检查用户输入是否包含注入攻击、越权指令。工具执行侧检查LLM生成的工具参数是否合法,比如文件路径是否在允许目录内。输出侧检查模型生成的内容是否符合规范,比如是否泄露了系统Prompt、是否包含不当内容。
Guardrail的实现可以是规则引擎,也可以是另一个LLM调用。规则引擎快但覆盖有限,LLM判断准但增加延迟和成本。我的策略是规则引擎做第一道过滤,拦截明显的违规;LLM做第二道,处理规则覆盖不到的边缘情况。两道都通过才返回给用户。
7.3 成本监控与预算告警
成本是AI应用的生命线。我在接入层记录每次调用的Token消耗,按用户、按任务类型、按模型聚合。设置日预算和周预算,超过80%时告警,超过100%时自动降级到便宜模型或限制请求频率。
成本优化有几个实操技巧:第一,缓存高频请求的结果,比如常见问题的回答;第二,对长文档做预处理,提取关键段落再送给模型,而不是全文塞入;第三,用流式输出让用户感知更快,同时可以在用户中断时提前停止生成,节省Token。
我实测过一个场景,加了结果缓存后,重复问题的成本降为零,整体成本下降约35%。缓存的关键是设计好缓存键,一般用“用户意图+关键实体”做键,而不是原始输入文本,这样语义相同但表述不同的请求也能命中。
8. 常见问题与排查技巧实录
8.1 Agent不调用工具或调用错误工具怎么办
这是最高频的问题。排查顺序是:先看工具描述是否清晰,描述模糊是首要原因;再看工具数量是否过多,超过15个工具时模型选择准确率明显下降;然后看Prompt里工具调用的示例是否充分,给一两个few-shot示例能显著提升准确率。
如果工具描述没问题但还是选错,可以试试在系统Prompt里加一句“在调用工具前,先说明你为什么选择这个工具”。这个“思考链”能迫使模型更谨慎地选择。我实测下来,加了这句话后工具选择准确率从七成提升到九成左右。
8.2 上下文超长导致模型“失忆”
上下文超长的典型表现是模型忘记了前面的指令或信息。排查时先确认实际Token数,用tiktoken之类的工具精确计算,不要凭感觉。然后检查是否有不必要的长文本被塞入,比如完整的工具返回结果、重复的系统指令。
解决方案按优先级:先做上下文压缩,把早期对话摘要化;再优化工具返回,截断长结果;最后考虑换更大窗口的模型。但换模型是最后手段,因为成本和延迟都会增加。我一般优先在前两层解决。
8.3 工具调用超时与重试策略
工具超时的排查要看是工具本身慢还是网络问题。如果是工具本身慢,考虑加缓存或异步化;如果是网络问题,加重试。重试策略我一般用指数退避,第一次等1秒,第二次2秒,第三次4秒,最多重试3次。重试时要区分错误类型,参数错误重试没用,网络超时重试有意义。
注意:写操作的重试要特别小心。查询可以随便重试,但下单、发消息这类写操作重试可能导致重复。我的做法是给写操作加幂等键,服务端根据幂等键去重。
8.4 常见问题速查表
| 问题现象 | 可能原因 | 排查动作 | 解决方案 |
|---|---|---|---|
| Agent不调工具 | 工具描述模糊 | 检查描述文本 | 补充使用场景和示例 |
| 工具选错 | 工具数量过多 | 统计工具总数 | 分组加载或语义筛选 |
| 上下文失忆 | Token超限 | 精确计算Token | 摘要压缩+结果截断 |
| 工具超时 | 工具慢或网络差 | 看工具耗时分布 | 缓存+指数退避重试 |
| 成本飙升 | 输出过长或缓存缺失 | 看Token消耗分布 | 限制输出+加缓存 |
| 死循环 | 无终止条件 | 看步数分布 | 加最大步数+重复检测 |
这张表是我从实际项目中积累的,每次遇到新问题就往里加一行。建议你也维护一份自己的速查表,比翻文档快得多。
9. 我在实际项目中的几点体会
做AI应用架构这几年,最大的体会是:架构的价值不在于多先进,而在于多稳定。我见过太多项目追求最新的框架、最炫的Agent模式,结果上线后各种边界情况处理不了。反而是那些分层清晰、接口稳定、有完善降级和监控的系统,能扛住真实流量的考验。
另一个体会是,MCP这类标准协议值得尽早接入。虽然初期看起来多了一层抽象,但当你需要接第五个、第十个工具时,标准协议的优势就体现出来了。工具生态的复用性会随着接入的工具数量增加而指数级提升。
最后分享一个小技巧:在Agent的系统Prompt里加一句“如果不确定,先问用户而不是猜”。这句话能减少大量因为模型臆测导致的错误。很多Agent项目的问题不是能力不够,而是太自信。让模型学会说“我不确定”,比让它多会一个工具更有价值。
这个架构后续还可以扩展的方向包括:多模态输入的统一处理、Agent之间的标准化通信协议、以及基于反馈的自动Prompt优化。但这些都是锦上添花,把上面六层的基础打牢,才是大多数AI应用项目最该做的事。