1. 从一张架构图说起:AI应用到底该怎么搭
很多人第一次接触AI应用开发,脑子里冒出来的第一个问题就是:我到底该从哪儿下手?是直接调个大模型接口就完事,还是得搞一套完整的工程体系?我刚开始做AI应用那会儿也有这个困惑,后来踩了不少坑才慢慢理清楚——AI应用架构设计这件事,本质上不是让你去造一个多复杂的系统,而是让你想明白数据从哪来、模型怎么调、结果怎么用这三件事怎么串起来。
“图解AI应用架构设计”这个主题,核心就是要把这套串联逻辑用可视化的方式讲清楚。它解决的不是某个具体算法问题,而是帮你在动手写代码之前,先在脑子里画出一张完整的路线图。适合谁来参考?我觉得三类人最需要:一是刚转行做AI应用开发的程序员,二是在传统软件团队里被安排去“接个大模型”的工程师,三是想搞清楚Agent、LLM、MCP这些概念之间到底是什么关系的技术负责人。
这篇文章我会从架构分层讲起,把LLM、Agent、MCP这几个核心组件在架构中的位置和职责拆开揉碎,然后给出可落地的实操方案,最后把我自己踩过的坑和排查经验整理出来。你不需要有很深的AI背景,只要写过代码、理解基本的API调用逻辑,就能跟着往下走。
2. AI应用架构的核心分层与组件拆解
2.1 为什么需要分层:从“一把梭”到“各司其职”
我见过太多一开始“一把梭”的AI应用:前端直接调大模型API,拿到结果就渲染。这种写法在demo阶段没问题,但一旦要加记忆、要接工具、要做多轮对话,代码就会变成一团乱麻。架构分层的意义就在这儿——让每一层只关心自己的事,层与层之间通过明确的接口通信。
一个典型的AI应用架构,我会把它分成四层:接入层、编排层、能力层、基础设施层。接入层负责和用户交互,编排层负责决定“什么时候调什么”,能力层提供LLM调用、工具执行、记忆存储等具体能力,基础设施层则是模型服务、向量数据库、日志监控这些底座。
这么分的理由很简单:编排逻辑是最容易变的,今天用这个模型,明天换那个模型,后天加一个新工具,如果编排和具体能力耦合在一起,每次改动都是牵一发动全身。把编排独立出来之后,换模型只需要改能力层的适配器,编排层几乎不用动。
2.2 LLM在架构中的角色:不只是“文本生成器”
很多人把LLM当成一个“输入文本、输出文本”的黑盒,这在简单场景下够用,但在架构设计层面就太粗糙了。LLM在AI应用里其实承担了三个角色:推理引擎、决策核心、格式转换器。
作为推理引擎,它负责理解用户意图、做逻辑判断;作为决策核心,它决定要不要调用某个工具、调用哪个工具;作为格式转换器,它把非结构化输入转成结构化输出,比如把一段自然语言转成JSON。理解这三个角色很重要,因为它直接决定了你在架构里怎么设计LLM的调用接口——如果只当文本生成器,接口就很简单;如果要当决策核心,就需要把工具描述、上下文、历史对话都传进去。
实际做的时候,我会在能力层封装一个统一的LLM适配器,对外暴露的方法不只是generate(prompt),而是reason(context, tools, memory)这种更贴近实际需求的接口。这样编排层调用的时候就不用关心底层是哪个模型、用什么参数。
2.3 Agent的定位:编排层的“大脑”
Agent这个词现在被用得有点泛,但在架构设计语境下,它的定位很明确:Agent是编排层的核心执行单元。它接收用户输入,结合记忆和工具,决定下一步做什么,然后执行,再根据结果决定是否继续循环。
Agent和普通LLM调用的区别在于“自主性”。普通调用是你告诉它做什么它就做什么,Agent是你告诉它目标,它自己决定怎么做。这个区别在架构上的体现就是:Agent需要一个循环控制机制,需要工具注册和发现机制,需要状态管理。
我自己的经验是,Agent的复杂度要控制。见过一些项目,Agent里塞了十几个工具、五六层嵌套决策,最后调试的时候根本不知道是哪一步出了问题。我的建议是,单个Agent的工具数量控制在5到8个,超过这个数就考虑拆成多个Agent协作。
2.4 MCP的位置:能力层的“标准化插座”
MCP这个概念这两年被讨论得很多,但很多人还是不太清楚它在架构里到底放在哪。我的理解是:MCP是能力层的一种标准化协议,它让工具和资源的接入变得像插插座一样统一。
在没有MCP之前,每接一个外部工具都要写一套适配代码,工具A用HTTP、工具B用SDK、工具C用命令行,编排层要处理各种不同的调用方式。MCP做的事情就是把这些差异屏蔽掉,对外提供统一的工具描述和调用接口。编排层只需要知道“有一个工具叫search,接受query参数”,不用关心它底层是怎么实现的。
在架构图上,MCP通常画在能力层和外部资源之间,作为一个协议适配层。它不负责决策,只负责“把请求翻译成外部工具能懂的形式,把结果翻译回编排层能懂的形式”。
2.5 一张完整的架构图应该包含什么
把上面这些串起来,一张完整的AI应用架构图应该包含以下元素:
| 层级 | 核心组件 | 职责 |
|---|---|---|
| 接入层 | Web/App/API Gateway | 用户交互、鉴权、限流 |
| 编排层 | Agent Runtime、Workflow Engine | 决策、循环控制、状态管理 |
| 能力层 | LLM Adapter、Tool Registry、Memory Store、MCP Client | 模型调用、工具执行、记忆读写 |
| 基础设施层 | Model Serving、Vector DB、Logging、Monitoring | 模型托管、向量检索、可观测性 |
画图的时候,我习惯用从左到右的数据流方向:用户请求从左边进来,经过接入层、编排层、能力层,最后到基础设施层,响应再反向流回去。这样画的好处是,任何一层出问题,你都能快速定位是“进来的路上”还是“出去的路上”出了问题。
3. 核心组件深度解析与选型实操
3.1 LLM选型:别只看跑分,要看场景匹配度
选LLM这件事,我的原则是“场景优先,成本其次,跑分最后”。跑分高的模型不一定适合你的场景,比如有些模型在通用benchmark上很强,但在特定领域的术语理解上可能不如一个经过精调的 smaller model。
具体怎么选?我会从四个维度评估:
- 任务类型:是生成类、分类类、抽取类还是推理类?生成类对模型创造力要求高,抽取类对格式遵循要求高。
- 延迟要求:实时对话场景要求首token延迟低,离线批处理场景可以接受高延迟。
- 成本预算:按token计费的话,要估算日均调用量和平均token数。
- 部署方式:是调云端API还是本地部署?本地部署要考虑显存和量化方案。
我自己的做法是,先用云端API快速验证效果,确定prompt和流程没问题之后,再评估是否需要用本地模型替换。本地模型的优势是数据不出域、成本可控,劣势是维护成本高、效果可能打折扣。
3.2 Agent框架选型:从“够用”出发
Agent框架现在选择很多,但我建议从“够用”出发,不要一上来就上最复杂的。我按复杂度分三档:
第一档:单Agent + 工具调用。适合大多数场景,一个Agent,注册几个工具,循环执行直到任务完成。实现简单,调试方便。
第二档:多Agent协作。适合需要分工的场景,比如一个Agent负责规划、一个负责执行、一个负责审核。复杂度上升,但可控。
第三档:带记忆和反思的Agent。适合长期运行、需要积累经验的场景。实现复杂度最高,调试也最麻烦。
我的建议是,除非你的场景确实需要,否则从第一档开始。我见过太多项目一上来就搞多Agent,结果连单Agent的prompt都没调明白。
3.3 MCP接入实操:以工具注册为例
MCP的接入流程,我用一个具体的工具注册例子来说明。假设你要接入一个“天气查询”工具,通过MCP协议暴露给Agent。
第一步,定义工具描述。MCP要求工具描述包含名称、描述、参数schema。这个描述会传给LLM,让LLM知道有这个工具可用。
{ "name": "get_weather", "description": "查询指定城市的当前天气", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,如北京、上海" } }, "required": ["city"] } }第二步,实现工具执行逻辑。这部分是实际调用天气API的代码,MCP不关心你怎么实现,只要求你返回符合格式的结果。
第三步,注册到MCP Client。编排层通过MCP Client发现这个工具,在需要的时候调用。
注意:工具描述的质量直接影响LLM的调用准确率。描述要简洁明确,参数说明要写清楚格式要求。我踩过的坑是描述写得太模糊,LLM经常传错参数类型。
3.4 记忆系统设计:短期、长期、工作记忆
记忆系统是很多AI应用容易忽略的部分,但它是让应用“越用越聪明”的关键。我把记忆分成三类:
短期记忆:当前对话的上下文,通常就是消息列表。实现简单,但受限于模型上下文窗口。
长期记忆:跨对话持久化的信息,比如用户偏好、历史事实。通常用向量数据库存储,检索时按相似度召回。
工作记忆:Agent在执行任务过程中的中间状态,比如已经完成了哪些步骤、当前在做什么。这个通常存在Agent的运行时状态里。
设计记忆系统的时候,关键问题是“什么时候写入、什么时候读取、什么时候遗忘”。我的经验是,写入要克制,不是所有对话都值得存;读取要精准,用相似度阈值过滤;遗忘要有策略,定期清理低价值记忆。
3.5 可观测性:别等出问题了才想起来
AI应用的可观测性和传统软件不太一样,因为LLM的输出是不确定的,你没法用简单的断言来判断对错。我通常会记录以下几类数据:
- 每次LLM调用的完整输入输出:包括prompt、参数、返回结果、token消耗、延迟。
- Agent的决策轨迹:每一步的思考、选择了哪个工具、工具返回了什么。
- 工具调用的成功率和延迟:哪些工具经常失败,哪些工具响应慢。
- 用户反馈:点赞、点踩、重新生成,这些是评估效果的重要信号。
这些数据我一般会存到结构化的日志系统里,方便后续做分析和回放。回放功能特别有用,当用户反馈某个case有问题时,你可以把当时的完整调用链调出来看是哪一步出了问题。
4. 从零搭建一个AI应用的完整实操
4.1 环境准备与项目结构
假设我们要搭建一个“智能客服助手”,能回答产品问题、查询订单状态、转接人工。我用的技术栈是Python + FastAPI + 一个LLM API + 向量数据库。
项目结构我会这样组织:
smart-assistant/ ├── app/ │ ├── main.py # FastAPI入口 │ ├── api/ │ │ └── chat.py # 对话接口 │ ├── orchestrator/ │ │ ├── agent.py # Agent核心逻辑 │ │ └── workflow.py # 工作流定义 │ ├── capabilities/ │ │ ├── llm_adapter.py # LLM适配器 │ │ ├── tool_registry.py # 工具注册 │ │ └── memory.py # 记忆管理 │ ├── tools/ │ │ ├── order_query.py # 订单查询工具 │ │ └── faq_search.py # FAQ检索工具 │ └── config.py # 配置 ├── tests/ └── requirements.txt这个结构的好处是职责清晰,编排逻辑在orchestrator里,能力实现在capabilities里,具体工具在tools里。加新工具只需要在tools里加文件,在tool_registry里注册。
4.2 LLM适配器的实现
LLM适配器的作用是屏蔽不同模型API的差异。我通常会定义一个基类,然后为每个模型实现一个子类。
class BaseLLMAdapter: def reason(self, messages, tools=None, **kwargs): raise NotImplementedError class OpenAIAdapter(BaseLLMAdapter): def reason(self, messages, tools=None, **kwargs): # 调用OpenAI API,处理tools参数 response = client.chat.completions.create( model=self.model, messages=messages, tools=tools, **kwargs ) return self._parse_response(response)这样编排层调用的时候只需要llm.reason(messages, tools),不用关心底层是哪个模型。换模型的时候只需要换适配器。
4.3 Agent循环的实现
Agent的核心是一个循环:思考、行动、观察、再思考。我用伪代码说明:
def run_agent(user_input, max_steps=10): messages = build_initial_messages(user_input) for step in range(max_steps): response = llm.reason(messages, tools=tool_registry.get_schemas()) if response.has_tool_call(): tool_result = execute_tool(response.tool_call) messages.append(tool_result) else: return response.content return "达到最大步数限制"这个循环里,关键点是max_steps的设置。设太小,复杂任务完不成;设太大,可能陷入死循环。我的经验是设8到12之间,同时加一个“连续两次调用同一工具且参数相同”的检测,如果出现就强制退出。
4.4 工具注册与MCP接入
工具注册我用一个注册表模式,每个工具是一个类,实现execute方法,同时提供schema描述。
class ToolRegistry: def __init__(self): self.tools = {} def register(self, name, tool): self.tools[name] = tool def get_schemas(self): return [tool.schema for tool in self.tools.values()] def execute(self, name, params): return self.tools[name].execute(**params)MCP接入的话,我会把MCP Client封装成一个特殊的工具,它内部维护了所有通过MCP协议暴露的工具列表。当Agent调用这个工具时,MCP Client负责转发到实际的外部工具。
4.5 记忆的读写实现
短期记忆就是消息列表,这个不用多说。长期记忆我用向量数据库实现,写入的时候把对话摘要embedding后存入,读取的时候用当前输入做相似度检索。
class LongTermMemory: def __init__(self, vector_store): self.store = vector_store def write(self, content, metadata): embedding = embed(content) self.store.upsert(embedding, content, metadata) def read(self, query, top_k=3, threshold=0.7): embedding = embed(query) results = self.store.search(embedding, top_k) return [r for r in results if r.score > threshold]阈值设置很关键,太低会召回不相关的内容干扰LLM,太高会漏掉有用信息。我一般从0.7开始调,根据实际效果微调。
4.6 完整调用链的串联
把上面这些串起来,一次完整的对话流程是这样的:
- 用户输入到达接入层,鉴权、限流后转发到编排层。
- 编排层从长期记忆里检索相关历史,和当前输入一起构建messages。
- Agent进入循环,调用LLM做决策。
- 如果需要调工具,通过工具注册表执行,结果追加到messages。
- 循环直到LLM给出最终回复。
- 回复返回给用户,同时异步写入长期记忆和日志。
这个流程里,第2步的记忆检索和第6步的记忆写入是异步的,不阻塞主流程。日志记录也是异步的,避免影响响应延迟。
5. 常见问题与排查技巧实录
5.1 LLM调用失败:从报错信息定位问题
LLM调用失败是最常见的问题,报错信息通常能给出线索。我整理了一个速查表:
| 报错关键词 | 可能原因 | 排查方向 |
|---|---|---|
| rate limit | 调用频率超限 | 检查QPS,加退避重试 |
| context length | 上下文超长 | 检查messages长度,做截断或摘要 |
| invalid schema | 工具schema格式错误 | 检查JSON schema是否符合规范 |
| timeout | 网络或模型响应慢 | 加超时设置,考虑换模型 |
| content filter | 内容被过滤 | 检查输入输出是否触发安全策略 |
我踩过的一个坑是工具schema里用了LLM不支持的JSON schema特性,比如oneOf嵌套太深,导致LLM解析失败。后来我把schema简化成扁平结构就好了。
5.2 Agent陷入死循环:识别与打断
Agent死循环的表现是反复调用同一个工具,或者在不同工具之间来回跳转。识别方法是记录每一步的工具调用,如果连续N步的工具调用模式重复,就判定为死循环。
打断策略我一般用两个:一是设置最大步数,二是检测重复模式。重复模式的检测可以用简单的哈希,把工具名和参数拼起来做哈希,如果连续三次哈希相同就退出。
提示:死循环很多时候是prompt的问题。检查一下工具描述是否清晰,是否给了LLM足够的退出条件说明。
5.3 工具调用参数错误:schema设计与prompt双管齐下
工具调用参数错误的表现是LLM传了错误的参数类型或缺少必填参数。解决方法有两个层面:
schema层面,参数类型要明确,枚举值要列全,必填项要标注。我习惯在参数描述里加示例,比如"city": {"type": "string", "description": "城市名称,如北京"}。
prompt层面,在系统提示里说明工具的使用场景和参数要求。比如“查询订单时,order_id必须是纯数字”。
5.4 记忆检索不准确:阈值与embedding的双重调优
记忆检索不准确通常有两个原因:阈值设置不当,或者embedding模型不适合你的领域。阈值的问题前面说过了,embedding的问题需要换模型或者做微调。
我的做法是,先用通用embedding模型跑一批测试case,看召回的相关性。如果相关性差,考虑换一个在中文或特定领域上表现更好的模型。如果换模型还不行,就需要考虑用领域数据做微调。
5.5 响应延迟过高:从链路各环节找瓶颈
响应延迟高的时候,我会按链路逐段排查:
- 接入层:鉴权和限流是否耗时?
- 编排层:记忆检索是否同步阻塞?
- 能力层:LLM调用延迟多少?工具调用延迟多少?
- 基础设施层:向量检索延迟多少?
大部分情况下,瓶颈在LLM调用。优化手段包括:用更小的模型、减少上下文长度、开启流式输出让用户先看到部分结果。
5.6 几个我踩过的坑和对应技巧
坑一:prompt里工具描述太长导致token浪费。我一开始把每个工具的完整文档都塞进prompt,结果token消耗巨大。后来改成只放工具名和一句话描述,详细说明放在工具执行时的参数校验里。
坑二:异步写入记忆导致数据不一致。有次用户刚说完偏好,下一轮对话却没读到。原因是异步写入还没完成。后来改成写入完成后再返回响应,或者用消息队列保证顺序。
坑三:日志记录太多导致存储成本高。全量记录LLM输入输出很占空间。后来我改成只记录摘要和关键字段,完整内容按需采样。
坑四:工具版本升级导致schema不兼容。工具升级后参数变了,但Agent还在用旧schema。后来我加了版本号,schema变更时同步更新注册表。
这些经验都是实际项目中积累的,文档里通常不会写,但真正做起来的时候会反复遇到。希望对你搭建自己的AI应用有帮助。