ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI应用架构设计:从LLM选型到Agent与MCP的工程实践

AI应用架构设计:从LLM选型到Agent与MCP的工程实践

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。

排查思路:

  1. 检查工具描述:工具描述是否清晰说明了用途?如果描述太模糊,LLM不知道什么时候该用。
  2. 检查System Prompt:是否明确告诉LLM“涉及数据查询必须调用工具”?
  3. 检查工具数量:工具太多会导致LLM选择困难,尝试减少工具数量或分组。
  4. 检查模型能力:小模型可能不具备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应用的地基就稳了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进