1. 从一张架构图说起:AI应用到底该怎么搭
很多人第一次接触AI应用开发,脑子里冒出来的第一个念头就是“调个API不就完了”。我刚开始也这么想,直到真正接手一个需要多轮对话、工具调用、状态管理的项目,才发现事情远没有那么简单。AI应用架构设计这件事,本质上是在解决一个核心矛盾:大模型本身是一个概率性的、无状态的文本生成器,而我们要做的应用却需要确定性、有状态、可追溯。这个矛盾不解决,做出来的东西永远停留在demo阶段。
所谓AI应用架构,说白了就是回答四个问题:模型怎么选、上下文怎么管、工具怎么接、状态怎么存。这四个问题对应到具体的技术选型上,就是LLM选型与接入、Prompt与记忆管理、Agent与工具调用协议、以及持久化与可观测性。我见过太多团队在这四个问题上反复横跳,最后项目延期三个月,上线后发现连最基本的“记住用户上一句话”都做不到。
这篇文章适合谁看?如果你正在做AI应用开发,不管是刚入门的Java后端想转AI方向,还是已经在做Agent开发但总觉得架构不够稳,又或者你是技术负责人需要评估AI项目的技术方案,那这篇内容应该能帮你省下不少试错时间。我会从架构设计的角度,把LLM、Agent、MCP这些概念串起来,讲清楚它们各自在架构中的位置和协作方式,而不是孤立地解释名词。
先给一个整体判断:AI应用架构的核心不是模型本身,而是模型与外部世界之间的那层“适配层”。这层适配层决定了你的应用能不能从“玩具”变成“产品”。下面我按架构设计的逻辑顺序,从整体思路到具体实现,再到问题排查,一层层拆开讲。
2. 架构设计的整体思路与核心分层
2.1 为什么不能把LLM直接塞进业务代码
我见过最粗暴的做法是在Controller里直接写一行调用大模型API的代码,然后把返回结果拼到Response里。这种做法在原型阶段没问题,但一旦业务逻辑超过三个分支,代码就会变成一团乱麻。原因很简单:LLM的调用是异步的、耗时的、可能失败的,而且返回内容是不确定的。把这四件事和业务逻辑混在一起,等于把不确定性引入了核心链路。
正确的做法是在业务逻辑和LLM之间加一层编排层。这层编排层负责的事情包括:决定什么时候调用LLM、调用哪个模型、传什么上下文、怎么解析返回结果、失败了怎么重试、超时了怎么降级。这层编排层就是AI应用架构的核心,我习惯把它叫做“AI Gateway”或者“Agent Runtime”。
从分层角度看,一个完整的AI应用架构通常包含五层:
- 接入层:处理HTTP/WebSocket请求,做鉴权和限流
- 编排层:Agent逻辑、工具调用决策、多轮对话管理
- 模型层:LLM接入、模型路由、Prompt模板管理
- 工具层:MCP协议实现、外部API封装、函数调用
- 基础设施层:向量数据库、缓存、日志、监控
这五层不是必须全部都有,但缺了哪一层,对应的能力就会缺失。比如没有工具层,你的AI应用就只能聊天,不能做事;没有基础设施层,出了问题你连日志都查不到。
2.2 LLM选型:不是越贵越好,而是越合适越好
选模型这件事,我的经验是先看任务类型,再看成本,最后看延迟。任务类型决定了你需要什么级别的模型能力。如果是简单的意图分类、实体抽取,7B参数的小模型微调一下就够了,没必要上GPT-4。如果是复杂的多步推理、代码生成,那必须用大参数模型。
这里有个常见的误区:很多人觉得模型越大越好,结果用最贵的模型做最简单的任务,成本直接爆炸。我一般会建议按任务分级:
| 任务类型 | 推荐模型级别 | 典型场景 | 成本考量 |
|---|---|---|---|
| 分类/抽取 | 小模型(7B-13B) | 意图识别、情感分析 | 极低 |
| 对话/问答 | 中模型(70B级别) | 客服机器人、知识问答 | 中等 |
| 推理/生成 | 大模型(GPT-4级别) | 代码生成、复杂规划 | 较高 |
| 多模态 | 专用多模态模型 | 图像理解、文档解析 | 高 |
选型时还要考虑模型路由的问题。一个成熟的AI应用不应该只依赖一个模型,而是根据任务动态路由。比如用户问“今天天气怎么样”,路由到小模型加天气API就够了;用户问“帮我分析这份财报”,才路由到大模型。这种动态路由的设计,能把整体成本降低60%以上。
2.3 Agent架构:从“问答”到“做事”的关键跃迁
Agent这个词现在被用得有点泛滥,但它的核心思想其实很清晰:让LLM具备自主决策和工具使用的能力。传统的LLM应用是“你问我答”,Agent是“你给目标,我自己想办法完成”。
Agent架构的核心组件包括:
- 规划器(Planner):把用户目标拆解成可执行的步骤
- 执行器(Executor):调用工具执行每一步
- 记忆(Memory):存储对话历史和中间结果
- 反思器(Reflector):评估执行结果,决定是否继续或重试
我实测下来,Agent架构最容易出问题的地方是规划器和执行器的边界。如果规划器规划得太粗,执行器就不知道怎么做;如果规划得太细,又失去了灵活性。我的经验是让规划器只规划到“调用哪个工具”这一层,具体的参数由执行器根据上下文填充。
还有一个关键设计是容错。Agent执行过程中,工具调用失败是常态,不是异常。所以架构上必须支持:工具调用失败后自动重试、重试失败后换备选工具、备选也失败后回退到让LLM重新规划。这套机制不做好,Agent的可用性会非常差。
2.4 MCP协议:工具调用的标准化尝试
MCP(Model Context Protocol)是最近比较热的一个概念,它的目标是标准化LLM与外部工具的交互方式。在没有MCP之前,每个AI应用接入工具的方式都不一样,有的用Function Calling,有的用自定义JSON Schema,有的直接拼Prompt。MCP试图统一这件事。
MCP的核心思想是:工具提供方实现一个MCP Server,暴露标准的接口描述;AI应用作为MCP Client,通过标准协议发现和调用工具。这样工具就可以跨应用复用,不用每个应用都重新适配一遍。
从架构角度看,MCP带来的最大价值是解耦。工具的实现和AI应用的逻辑分离,工具可以独立部署、独立升级。这对于企业级应用特别重要,因为工具往往涉及内部系统,不可能和AI应用部署在一起。
不过MCP目前还在演进阶段,实际落地时要注意版本兼容性和性能开销。我试过用MCP接入一个内部API,发现每次工具调用都要走一次协议握手,延迟比直接Function Calling高了30%左右。所以如果是性能敏感的场景,可能还需要权衡。
3. 核心细节解析与实操要点
3.1 Prompt管理:别把Prompt硬编码在代码里
Prompt是AI应用的“源代码”,但很多人把它当字符串硬编码在Java或Python文件里。这种做法的问题在于:改一个Prompt要重新部署,没法做A/B测试,没法追踪版本,多人协作时冲突不断。
我的做法是Prompt模板化管理。具体来说,把Prompt抽成独立的模板文件,用占位符表示变量,运行时渲染。模板文件放在配置中心或数据库里,支持热更新。这样产品经理改Prompt不用找开发,运营可以做A/B测试,出问题可以快速回滚。
一个典型的Prompt模板结构包括:
name: customer_service_reply version: v2.3 model: gpt-4 temperature: 0.7 max_tokens: 500 system: | 你是一个专业的客服助手,语气友好,回答简洁。 如果用户问题超出你的知识范围,请引导用户联系人工客服。 user: | 用户问题:{{question}} 历史对话:{{history}} 相关知识:{{context}}这里有几个实操要点:temperature参数要根据任务调,分类任务用0.1,创意生成用0.8;max_tokens要设上限,防止模型输出过长导致费用失控;system prompt要写清楚边界,明确告诉模型什么能做、什么不能做。
注意:Prompt模板的版本管理很重要。我建议每次修改都保留旧版本,线上出问题时可以快速对比。我踩过的坑是直接覆盖了旧版本,结果新版本效果变差,想回滚都回不去。
3.2 上下文管理:Token是有限的,记忆是无限的
LLM的上下文窗口是有限的,但对话历史是无限增长的。怎么在有限的窗口里塞进最有用的信息,这是上下文管理的核心问题。
我的策略是分层记忆:
- 短期记忆:最近N轮对话,直接放进上下文
- 中期记忆:对话摘要,用LLM压缩历史对话
- 长期记忆:向量化存储,按需检索
具体实现上,短期记忆保留最近5-10轮,中期记忆每10轮生成一次摘要,长期记忆把关键信息向量化存入向量数据库。每次请求时,根据当前问题检索相关的长期记忆,和短期记忆一起拼进上下文。
这里有个参数需要计算:假设每轮对话平均200个token,模型上下文窗口是8K,那么最多能放40轮对话。但实际不能放满,因为还要留空间给系统Prompt和模型输出。我的经验是上下文使用率控制在70%以内,留30%的buffer。
向量检索的相似度阈值也需要调。阈值太高,检索不到相关内容;阈值太低,会引入噪音。我一般从0.7开始试,根据实际效果调整。如果发现模型经常“答非所问”,可能是检索到了不相关的记忆,需要提高阈值。
3.3 工具调用:Function Calling的坑与技巧
Function Calling是Agent执行任务的基础能力。它的原理是:把工具的描述(名称、参数、用途)传给LLM,LLM决定是否调用、调用哪个、传什么参数。
实操中最大的坑是参数格式。LLM生成的参数经常不符合Schema定义,比如该传数字的传了字符串,该传数组的传了单个值。我的做法是在工具层做参数校验和转换,不要指望LLM每次都生成完美参数。
另一个坑是工具描述。工具描述写得好不好,直接决定LLM能不能正确选择工具。我总结了几条经验:
- 工具名称要语义化,用动词开头,比如
search_weather而不是weather - 参数描述要具体,说明格式和取值范围
- 在描述里给出使用示例,LLM会参考
- 工具数量不要太多,超过20个LLM的选择准确率会明显下降
如果工具确实很多,可以分组,先让LLM选组,再选具体工具。这种两级选择的方式,我实测能把准确率从60%提升到85%以上。
3.4 状态管理:无状态LLM如何支撑有状态应用
LLM本身是无状态的,每次调用都是独立的。但AI应用需要记住用户是谁、之前聊了什么、任务执行到哪一步了。这个状态管理必须由应用层来做。
我的方案是用会话状态机。每个会话有一个状态对象,包含:用户ID、对话历史、当前任务、任务步骤、中间结果。这个状态对象存在Redis里,设置合理的过期时间。
状态机的设计要注意幂等性。因为网络抖动或超时,同一个请求可能被重试,如果状态更新不是幂等的,就会出现重复执行。我的做法是给每个操作分配唯一ID,状态更新前先检查这个ID是否已经处理过。
还有一个细节是状态快照。Agent执行多步任务时,每一步都应该保存状态快照。这样如果某一步失败,可以从上一个快照恢复,不用从头开始。这个设计在长任务场景下特别有用,能大幅提升用户体验。
4. 实操过程与核心环节实现
4.1 从零搭建一个Agent Runtime
假设我们要做一个“智能数据分析助手”,用户可以用自然语言查询数据库、生成图表、导出报告。这个场景涉及LLM、Agent、工具调用、状态管理,是一个比较完整的AI应用。
第一步:定义工具集
工具包括:query_database(执行SQL查询)、generate_chart(生成图表)、export_report(导出报告)、get_schema(获取数据库表结构)。每个工具用JSON Schema描述:
{ "name": "query_database", "description": "执行SQL查询并返回结果,只支持SELECT语句", "parameters": { "type": "object", "properties": { "sql": { "type": "string", "description": "要执行的SQL查询语句" }, "limit": { "type": "integer", "description": "返回结果的最大行数,默认100" } }, "required": ["sql"] } }第二步:实现Agent循环
Agent的核心是一个循环:调用LLM -> 解析工具调用 -> 执行工具 -> 把结果返回给LLM -> 继续循环,直到LLM决定不再调用工具。
def agent_loop(user_input, session): messages = session.get_history() messages.append({"role": "user", "content": user_input}) for step in range(MAX_STEPS): response = llm.chat(messages, tools=TOOLS) if response.tool_calls: for tool_call in response.tool_calls: result = execute_tool(tool_call) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": result }) else: session.save_history(messages) return response.content return "任务步骤过多,已终止"这里MAX_STEPS是关键参数,防止Agent陷入死循环。我一般设10-15步,根据任务复杂度调整。
第三步:状态持久化
每次循环结束后,把messages和session状态存入Redis。key用session:{session_id},过期时间设24小时。这样即使用户关闭页面再打开,对话也能继续。
第四步:错误处理与降级
工具执行失败时,不要把异常直接抛给LLM,而是返回结构化的错误信息:
{ "error": "SQL语法错误", "detail": "near 'SELEC': syntax error", "suggestion": "请检查SQL语句,确保以SELECT开头" }LLM看到这个错误信息后,会尝试修正SQL重新调用。如果连续失败3次,Agent应该放弃并告知用户。
4.2 MCP接入的实操步骤
MCP的接入流程分三步:发现工具、调用工具、处理结果。
发现工具:MCP Client向MCP Server发送list_tools请求,获取工具列表和Schema。这个过程通常在应用启动时做一次,缓存结果。
调用工具:MCP Client发送call_tool请求,带上工具名称和参数。MCP Server执行后返回结果。
处理结果:把MCP返回的结果转换成LLM能理解的格式,通常是JSON字符串。
实操中要注意超时设置。MCP工具调用可能涉及外部系统,延迟不可控。我一般设30秒超时,超时后返回错误让LLM决定是否重试。
还有一个坑是工具名称冲突。如果多个MCP Server提供了同名工具,需要加前缀区分。比如db.query和api.query。
4.3 多模型路由的实现
多模型路由的核心是一个路由决策函数。输入是用户请求,输出是模型标识。
def route_model(request): # 简单任务用小模型 if is_simple_task(request): return "small-model" # 需要推理用大模型 if needs_reasoning(request): return "large-model" # 多模态请求用多模态模型 if has_image(request): return "multimodal-model" return "default-model"is_simple_task的判断可以用规则,也可以用一个小模型做分类。我实测下来,用规则+关键词匹配能覆盖80%的场景,剩下的20%用模型分类。
路由的收益很明显:我做过一个项目,全量用大模型每月成本2万,加了路由后降到6千,效果几乎没有下降。
4.4 可观测性建设
AI应用的可观测性和传统应用不一样,需要额外记录:Prompt内容、模型返回、Token消耗、工具调用链路。
我的做法是全链路Trace。每个请求分配一个trace_id,所有LLM调用、工具调用都带上这个ID。日志系统按trace_id聚合,出问题时可以完整回放整个请求链路。
关键指标包括:
| 指标 | 说明 | 告警阈值 |
|---|---|---|
| LLM调用延迟 | P99延迟 | >5s |
| Token消耗 | 每请求平均Token | >2000 |
| 工具调用失败率 | 失败次数/总次数 | >10% |
| Agent步数 | 平均执行步数 | >8步 |
这些指标能帮你快速定位问题。比如Token消耗突然上升,可能是Prompt变长了或者检索到了太多记忆;工具失败率上升,可能是外部系统出了问题。
5. 常见问题与排查技巧实录
5.1 Agent不调用工具怎么办
这是最常见的问题。用户问“帮我查一下上个月的销售额”,Agent却直接编了一个数字回复,没有调用query_database。
排查思路:
- 检查工具描述:工具描述是否清晰说明了用途?如果描述太模糊,LLM不知道什么时候该用。
- 检查System Prompt:是否明确告诉LLM“涉及数据查询必须调用工具”?
- 检查工具数量:工具太多会导致LLM选择困难,尝试减少工具数量或分组。
- 检查模型能力:小模型可能不具备Function Calling能力,换大模型试试。
我的经验是,在System Prompt里加一句“你不具备数据查询能力,必须通过工具获取数据”,能显著提升工具调用率。
5.2 工具调用参数错误怎么处理
LLM生成的参数不符合Schema,比如该传数字的传了字符串。这种情况不要直接报错,而是做参数修复。
def fix_params(params, schema): for key, value in params.items(): expected_type = schema["properties"][key]["type"] if expected_type == "integer" and isinstance(value, str): params[key] = int(value) elif expected_type == "string" and isinstance(value, (int, float)): params[key] = str(value) return params如果修复不了,把错误信息返回给LLM,让它重新生成。我一般允许重试2次,2次都失败就放弃。
5.3 上下文超长怎么截断
对话轮次多了,上下文会超出模型窗口。截断策略很重要,不能简单地从头删。
我的策略是保留首尾,压缩中间:
- 保留System Prompt(必须)
- 保留最近5轮对话(必须)
- 中间的历史对话生成摘要
- 如果还是超长,从中间摘要里再压缩
摘要生成用一个小模型就够了,成本很低。摘要的Prompt要明确要求“保留关键信息,包括用户意图、已执行的操作、重要结论”。
5.4 模型返回格式不稳定怎么解析
LLM返回的JSON经常有格式问题,比如多了markdown标记、少了引号、多了注释。解析时要做容错处理。
def parse_json_safe(text): # 去掉markdown代码块标记 text = re.sub(r'```json\s*|\s*```', '', text) # 尝试直接解析 try: return json.loads(text) except: pass # 尝试提取JSON部分 match = re.search(r'\{.*\}', text, re.DOTALL) if match: try: return json.loads(match.group()) except: pass return None如果解析失败,把原始文本返回给LLM,让它重新生成。同时要在Prompt里强调“只返回JSON,不要有其他内容”。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| Agent不调用工具 | 工具描述不清 | 检查工具Schema | 优化描述,加示例 |
| 参数格式错误 | LLM生成不稳定 | 打印原始参数 | 参数修复+重试 |
| 上下文超长 | 对话轮次过多 | 统计Token数 | 分层记忆+摘要 |
| 返回格式错误 | Prompt不够明确 | 检查Prompt | 加格式约束+容错解析 |
| 响应延迟高 | 模型太大/工具慢 | 分段计时 | 模型路由+工具超时 |
| 成本失控 | Token消耗大 | 监控Token | 限制max_tokens+缓存 |
提示:这些问题我几乎每个项目都会遇到,建议在开发阶段就做好监控和日志,不要等到上线后才发现。
6. 一些踩坑后的个人体会
做AI应用架构这件事,最大的体会是不要试图让LLM做它不擅长的事。LLM擅长理解和生成,不擅长精确计算和状态管理。把这两件事交给传统代码,LLM只负责“决策”,系统稳定性会好很多。
另一个体会是缓存很重要。很多请求是重复的,比如相同的知识问答、相同的工具调用。加一层语义缓存,能把成本和延迟都降下来。我用Redis做缓存,key是请求的向量表示,相似度超过0.95就命中缓存。实测命中率能到30%左右。
最后说一个细节:Prompt里的示例比指令更有效。与其写“请用JSON格式返回”,不如直接给一个JSON示例。LLM对示例的遵循度远高于对指令的遵循度。这个技巧我在多个项目里验证过,能显著提升输出稳定性。
架构设计没有银弹,每个项目的情况都不一样。但核心原则是通用的:分层解耦、状态外置、容错优先、可观测。把这四点做好,AI应用的地基就稳了。