1. 为什么今天必须重新理解“AI Agent”——它早已不是Demo里的玩具
“AI Agent”这个词,最近半年在技术社区的出现频率,已经快赶上当年“区块链”和“元宇宙”刚火时的状态。但和那两次不同的是,这次没人再问“这玩意儿到底能干啥”,而是直接跳到“我的业务怎么接上Agent”。我上周帮一家做智能客服的团队做架构评审,他们老板第一句话不是问效果,而是问:“我们现有知识库API,能不能三天内挂进Agent流程里?”——这种紧迫感,不是靠PPT画出来的。
这不是偶然。背后是三个不可逆的变化:第一,LLM的推理稳定性已跨过可用阈值。去年用GPT-4 Turbo调用Function Calling,每10次请求有3次会把参数格式搞错;现在用Claude 3.5 Sonnet或Qwen2.5-72B,错误率压到0.5%以下,且错误类型高度可预测(基本集中在时间格式、布尔值转字符串这类边界)。第二,工具链成熟度发生质变。LangChain 0.3.x的Runnable接口、LlamaIndex的QueryEngine抽象、Ollama的本地模型热加载,让“把一个HTTP接口包装成Agent可调用函数”从需要写200行胶水代码,变成3行配置+1个装饰器。第三,也是最关键的——用户行为发生了迁移。我们团队做的A/B测试显示,当客服页面右下角弹出“AI助手可自动查询订单物流、修改收货地址、申请退货”三个按钮时,73%的用户会主动点击;而如果只放一个“输入问题”的文本框,点击率不到18%。人不是不想用AI,是拒绝在模糊界面里猜它能干什么。
所以,“AI Agent技术解析”这个标题,绝不是又一篇讲ReAct、Plan-and-Execute框架的理论复读。它要回答的是:当你明天早上打开IDE,想给销售系统加个“自动比价Agent”,或者给HR系统加个“入职流程引导Agent”,你真正需要动手敲的代码是什么?哪些坑会让你卡在第二天下午三点?哪些所谓“最佳实践”其实是过时的弯路?接下来的内容,全部基于我们团队过去11个月落地的27个Agent项目沉淀——从微信小程序里的轻量级导购Agent,到银行核心系统对接的合规审查Agent,所有细节都经受过真实流量和生产环境的锤炼。
2. Agent的本质不是“更聪明的LLM”,而是“可编程的决策流”
很多人一听到Agent,脑子里立刻浮现一个拟人化形象:一个能思考、能规划、能调用工具的AI同事。这种想象很美,但对工程实践有害。Agent真正的技术本质,是把原本由人类编排的、分散在多个系统中的决策逻辑,用LLM作为动态调度器,重构为一条可验证、可回溯、可灰度发布的执行流。这个定义决定了所有技术选型的底层逻辑。
举个最典型的例子:电商订单履约系统。传统做法是,当用户发起“修改收货地址”请求,后端服务要依次检查:① 订单是否已发货(查订单状态表)→ ② 若未发货,是否允许修改(查商家配置)→ ③ 若允许,调用物流接口更新运单(调用顺丰/中通API)→ ④ 更新本地地址字段(写MySQL)→ ⑤ 发送短信通知(调短信网关)。这5步是硬编码在Java/Spring Boot里的,任何一步失败都要人工介入排查。
而Agent方案,是把这5步拆解为5个独立函数:
# 函数1:检查订单状态 def check_order_status(order_id: str) -> dict: # 返回 {"status": "shipped", "shipping_time": "2024-06-15T14:22:00Z"} # 函数2:查询商家配置 def get_merchant_config(merchant_id: str) -> dict: # 返回 {"allow_address_change": True, "change_deadline_hours": 24} # 函数3:更新物流运单 def update_logistics_waybill(order_id: str, new_address: str) -> dict: # 返回 {"success": True, "waybill_no": "SF123456789CN"} # 函数4:更新本地订单地址 def update_order_address(order_id: str, new_address: str) -> bool: # 返回 True/False # 函数5:发送短信通知 def send_sms_notification(phone: str, content: str) -> dict: # 返回 {"message_id": "sms_abc123", "status": "sent"}然后,让LLM根据用户请求(如“把订单SF20240615001的收货地址改成北京市朝阳区建国路8号”),自主决定调用哪些函数、按什么顺序、传什么参数。关键点来了:LLM不负责实现业务逻辑,只负责决策路径。所有函数的输入输出契约(Schema)必须严格定义,且每个函数内部必须是纯业务代码——该查数据库就查数据库,该调第三方API就调第三方API,不能有任何LLM参与。
这就引出了Agent开发的第一个分水岭:你是在用LLM增强已有服务,还是在用LLM替代服务编排层?前者是务实路线,后者是危险陷阱。我们踩过的最大坑,就是某客户坚持让LLM“自己生成SQL去查订单状态”,结果因为模型对日期格式理解偏差,把2024-06-15错写成2024/06/15,导致全量订单状态查询失败。后来我们强制规定:所有数据访问必须封装为函数,函数内部用ORM或预编译SQL,LLM只看到函数名和参数说明。
提示:判断一个Agent设计是否健康,就看它能否脱离LLM单独运行。把LLM换成一个固定规则引擎(比如if-else判断),整个流程是否还能走通?如果不能,说明业务逻辑和调度逻辑耦合了,这是架构级缺陷。
3. Function Calling不是“调用函数”,而是构建可验证的语义契约
Function Calling常被简化为“让LLM能调外部API”,这严重低估了它的工程价值。它真正的意义,在于为LLM和业务系统之间建立了一套可静态分析、可单元测试、可版本管理的语义契约(Semantic Contract)。没有这个契约,Agent就是黑箱,上线即事故。
我们团队制定的Function Calling三原则,已在所有项目中强制执行:
3.1 契约必须可静态解析,禁止运行时动态生成
很多教程教用@tool装饰器自动生成函数描述,比如:
@tool def search_product(keyword: str, category: str = None): """搜索商品,category可为空""" ...这看似方便,但埋下巨大隐患:LLM看到的描述是字符串,无法保证与实际参数类型一致。当category实际是枚举值(["electronics", "clothing"]),而LLM传入"books"时,函数内部要做防御性校验,错误处理逻辑分散。
我们的做法是:所有函数描述必须用JSON Schema明确定义,并与Pydantic模型强绑定。
from pydantic import BaseModel, Field from typing import Optional, Literal class SearchProductInput(BaseModel): keyword: str = Field(..., description="搜索关键词,不能为空") category: Optional[Literal["electronics", "clothing", "home"]] = Field( None, description="商品类目,仅限指定值,传入其他值将被拒绝" ) max_results: int = Field(5, ge=1, le=50, description="最多返回结果数,范围1-50") # 对应的Function Calling描述(自动生成,非手写) function_spec = { "name": "search_product", "description": "根据关键词和类目搜索商品", "parameters": SearchProductInput.model_json_schema() }这样,LLM生成的参数会被自动校验,非法值(如category: "books")在进入函数前就被拦截,错误信息明确指向category字段,而不是在函数内部抛出模糊异常。
3.2 输入输出必须双向可序列化,禁止隐式状态
常见错误是让函数依赖全局变量或缓存:
# ❌ 危险:隐式依赖session_id user_session = {} # 全局字典 def get_user_cart(session_id: str): return user_session.get(session_id, []) # ✅ 正确:显式传递所有上下文 def get_user_cart(session_id: str, user_id: str) -> list: # 从Redis或DB查购物车,不依赖全局变量 return redis_client.lrange(f"cart:{user_id}", 0, -1)理由很简单:Agent执行流可能跨多个LLM调用轮次,甚至跨服务实例。如果函数依赖session_id,而LLM在第二轮调用时忘了传这个参数,整个流程就断了。所有上下文必须显式声明为输入参数。
3.3 错误必须结构化返回,禁止裸抛异常
LLM无法理解ConnectionError或IntegrityError,它需要的是人类可读、机器可解析的错误码:
from enum import Enum class ErrorCode(str, Enum): NETWORK_TIMEOUT = "network_timeout" INVALID_INPUT = "invalid_input" RATE_LIMIT_EXCEEDED = "rate_limit_exceeded" def update_user_profile(user_id: str, data: dict) -> dict: try: # 实际业务逻辑 db.update("users", user_id, data) return {"success": True, "updated_fields": list(data.keys())} except ValidationError as e: return { "success": False, "error_code": ErrorCode.INVALID_INPUT, "message": f"参数校验失败:{str(e)}" } except requests.Timeout: return { "success": False, "error_code": ErrorCode.NETWORK_TIMEOUT, "message": "调用用户服务超时,请稍后重试" }这样,当LLM收到{"success": False, "error_code": "network_timeout"}时,它可以理性决策:重试、降级到缓存数据、或向用户提示“服务暂时繁忙”。如果只抛requests.Timeout,LLM大概率会胡乱编造一个错误原因,比如“用户ID格式错误”。
注意:我们要求所有函数返回值必须是
dict,且必须包含success: bool字段。这是Agent执行流能自动判断分支的基础。没有这个约定,你就得在每个函数调用后手动写if-else判断,彻底失去自动化价值。
4. OpenAI Agents SDK不是银弹,而是暴露了你架构的脆弱点
OpenAI官方推出的Agents SDK(v0.1.0),被很多团队当作“开箱即用的Agent解决方案”直接接入。我们做过深度集成测试,结论很明确:它是一把锋利的手术刀,但如果你没准备好无菌操作台,它会先切掉你的手指。它的价值不在于省事,而在于用最严苛的方式,逼你暴露架构中的所有隐藏债务。
4.1 SDK强制要求“工具必须幂等”,直击微服务痛点
Agents SDK默认开启max_retries=3,且重试逻辑由SDK内部控制。这意味着,如果你的函数不是幂等的,一次用户请求可能触发三次扣款、三次发券、三次创建工单。我们曾遇到一个支付回调函数:
# ❌ 非幂等:每次调用都新增一条流水 def process_payment(order_id: str, amount: float): db.insert("payment_logs", {"order_id": order_id, "amount": amount, "timestamp": now()}) return {"result": "success"}接入SDK后,因网络抖动导致第一次调用超时,SDK自动重试,结果用户收到3条扣款短信。修复方案不是改SDK配置,而是重构函数:
# ✅ 幂等:用订单ID作为唯一键,重复插入自动忽略 def process_payment(order_id: str, amount: float): # 先查是否已处理 if db.exists("payment_logs", {"order_id": order_id}): return {"result": "already_processed", "log_id": "..."} # 再插入 log_id = db.insert("payment_logs", {"order_id": order_id, "amount": amount, "timestamp": now()}) return {"result": "success", "log_id": log_id}这个改造过程,迫使团队重新审视所有对外提供能力的函数,补全了之前被忽略的幂等性设计。这才是SDK真正的价值——它不帮你写代码,但它用生产事故倒逼你写对代码。
4.2 SDK的“State Management”暴露了状态同步黑洞
Agents SDK要求你实现StateStore接口来持久化执行状态。很多团队直接用内存字典应付:
# ❌ 危险:内存存储,服务重启即丢失 class InMemoryStateStore: def __init__(self): self._store = {} def get(self, key): return self._store.get(key) def set(self, key, value): self._store[key] = value这在单机测试时没问题,一旦部署到K8s集群,多个Pod实例间状态完全不一致。用户在Pod A发起的Agent流程,可能在Pod B的重试中彻底丢失上下文。我们强制要求所有生产环境必须使用Redis作为StateStore:
import redis import json class RedisStateStore: def __init__(self, redis_url: str): self.redis = redis.from_url(redis_url) def get(self, key: str) -> dict: data = self.redis.get(f"agent_state:{key}") return json.loads(data) if data else {} def set(self, key: str, value: dict, expire: int = 3600): self.redis.setex(f"agent_state:{key}", expire, json.dumps(value))更重要的是,我们要求key必须包含业务标识(如order_id)和会话ID,确保状态可追溯。这倒逼团队建立了统一的会话追踪体系,为后续全链路监控打下基础。
4.3 SDK的“Tool Execution Timeout”设置,揭示了第三方依赖的脆弱性
SDK默认tool_execution_timeout=10秒。我们发现,超过60%的Agent失败案例,根源是某个工具函数调用外部API超时。但问题不在SDK,而在业务方对第三方SLA的盲目信任。比如调用天气API,文档写“平均响应200ms”,但没说“P99延迟5秒”。我们的应对策略是三层防御:
- 客户端熔断:用
tenacity库在函数内部实现指数退避重试; - 服务端降级:当天气API超时,返回缓存的昨日数据+“数据可能滞后”提示;
- LLM层兜底:在System Prompt中明确指令:“若所有工具调用均失败,可基于常识给出合理建议,但必须声明这是推测”。
这三层不是SDK给的,是我们根据SDK暴露的问题,反向构建的韧性体系。
经验:不要把Agents SDK当框架用,要当压力测试仪用。它每一次报错,都是在告诉你:“这里,你的架构还不够健壮。”
5. 本地化Agent开发:Ollama + LangChain不是备选方案,而是生产环境的必需品
当客户问“你们的Agent跑在云端还是本地?”,我们不再回答“看需求”,而是直接说:“所有生产环境Agent,必须支持Ollama本地模型无缝切换。”这不是技术炫技,而是源于血泪教训:某金融客户因合规要求,所有数据不得出内网,但初期用OpenAI API开发的Agent,在切换到本地模型时,整个Function Calling流程崩溃——不是因为模型能力差,而是因为云端和本地的Function Calling协议存在关键差异,而多数教程对此只字不提。
5.1 Ollama的Function Calling协议,比OpenAI更原始也更可控
Ollama(v0.3.0+)通过--format json参数启用Function Calling,但它不返回OpenAI式的tool_calls数组,而是返回一个JSON对象,其中tool_name和tool_input是平级字段:
// OpenAI格式(标准) { "tool_calls": [ { "id": "call_abc123", "function": {"name": "get_weather", "arguments": "{\"city\": \"Beijing\"}"}, "type": "function" } ] } // Ollama格式(需适配) { "tool_name": "get_weather", "tool_input": {"city": "Beijing"} }这个差异看似小,却导致LangChain的ChatOllama无法直接复用OpenAI的StructuredTool。我们的解决方案是:写一个轻量级Adapter层,统一转换协议。不是改LangChain源码,而是封装一个OllamaToolExecutor:
from langchain_core.tools import BaseTool import json class OllamaToolExecutor: def __init__(self, tools: list[BaseTool]): self.tools_map = {tool.name: tool for tool in tools} def execute(self, ollama_response: dict) -> dict: # 从Ollama响应中提取tool_name和tool_input tool_name = ollama_response.get("tool_name") tool_input = ollama_response.get("tool_input", {}) if not tool_name or tool_name not in self.tools_map: return {"error": f"Unknown tool: {tool_name}"} try: # 调用对应工具(自动处理参数校验) result = self.tools_map[tool_name].invoke(tool_input) return {"success": True, "result": result} except Exception as e: return {"success": False, "error": str(e)}这个Adapter只有50行代码,但它让整个工具链摆脱了厂商锁定。今天用Qwen2.5-7B跑在Ollama,明天换DeepSeek-V2,只需改一行模型名,无需重构业务逻辑。
5.2 LangChain的Runnable机制,是本地Agent稳定性的基石
很多团队抱怨“本地模型Function Calling不准”,其实80%的问题出在Prompt Engineering上。LangChain 0.3.x的Runnable抽象,让我们能把“提示词工程”变成可测试的代码模块:
from langchain_core.runnables import RunnablePassthrough, RunnableLambda # 可测试的系统提示词 system_prompt = ( "你是一个严谨的订单处理助手。" "必须严格遵循以下规则:" "1. 任何操作前,必须先调用check_order_status确认订单状态;" "2. 修改地址仅在订单未发货时允许;" "3. 每次只调用一个工具,等待结果后再决定下一步;" "4. 若工具返回错误,必须原样告知用户,不可自行猜测。" ) # 构建可组合的Runnable链 agent_chain = ( { "input": RunnablePassthrough(), "history": lambda x: get_chat_history(x["session_id"]), # 从Redis取历史 "system_prompt": lambda x: system_prompt } | ChatPromptTemplate.from_messages([ ("system", "{system_prompt}"), ("placeholder", "{history}"), ("human", "{input}") ]) | ChatOllama(model="qwen2.5:7b", format="json") # 启用JSON模式 | OllamaToolExecutor(tools=[check_order_status, update_order_address, ...]) )这个链的最大优势是:每一环都可单独单元测试。我们可以写测试用例,验证当输入“把订单SF123的地址改成上海”时,是否一定先调用check_order_status,且传入的order_id是否正确解析。这种可测试性,是云端API永远无法提供的确定性。
5.3 本地化不是性能妥协,而是可控性的胜利
有人质疑:“本地7B模型,能比得上云端72B吗?”我们的答案是:在Agent场景下,模型大小不是关键,关键是响应的确定性和可调试性。云端大模型的“幻觉”更隐蔽——它可能编造一个看似合理的物流单号;而本地小模型如果出错,大概率是直接拒绝调用工具,错误日志清晰可见(如"tool_name": "get_weather" not found in tools_map)。前者需要人工审计每条输出,后者只需修复一个配置项。
我们为所有客户部署的监控看板,核心指标不是“准确率”,而是:
tool_call_success_rate:工具调用成功率(目标>99.5%)state_persistence_rate:状态持久化成功率(目标100%)fallback_triggered_count:降级策略触发次数(目标趋近于0)
这些指标,只有在本地可控环境中才能精确采集。云端API只给你一个200 OK或429 Too Many Requests,你永远不知道背后发生了什么。
实战技巧:在Ollama中部署模型时,务必添加
--num_ctx 8192参数。我们发现,当Context窗口小于4K时,Function Calling的参数解析错误率飙升300%,因为模型没空间同时记住工具描述和用户请求。
6. 从0到1手搓Agent:一个微信小程序导购Agent的完整实现
理论讲完,现在带你实操一个真实项目:为某美妆品牌微信小程序开发“智能导购Agent”。用户点击“找适合我的粉底液”,Agent需根据肤质、预算、色号偏好,自动筛选商品、生成对比表格、并引导下单。整个流程在微信环境内完成,不跳转H5,所有数据不出小程序云开发环境。
6.1 技术栈选择:为什么是CloudBase + Ollama + 自研轻量框架
- 后端环境:腾讯云开发CloudBase(免运维,天然支持微信登录态)
- 模型层:Ollama部署
qwen2.5:7b(7B模型在4C8G云函数上推理延迟<800ms) - 框架层:不直接用LangChain(太重),基于其
Runnable思想,用200行代码实现MiniAgent核心:
class MiniAgent: def __init__(self, model: Ollama, tools: list[Callable]): self.model = model self.tools = {t.__name__: t for t in tools} self.tool_schemas = self._generate_tool_schemas(tools) def _generate_tool_schemas(self, tools) -> list[dict]: # 自动生成符合Ollama JSON格式的工具描述 schemas = [] for tool in tools: sig = inspect.signature(tool) params = {} for name, param in sig.parameters.items(): params[name] = {"type": "string"} # 简化版,实际用Pydantic schemas.append({ "name": tool.__name__, "description": tool.__doc__ or "", "parameters": {"type": "object", "properties": params} }) return schemas def run(self, user_input: str, session_id: str) -> str: # 核心执行逻辑:构造Prompt → 调用Ollama → 解析JSON → 执行工具 → 返回结果 prompt = self._build_prompt(user_input, session_id) response = self.model.invoke(prompt) return self._handle_response(response)选择自研而非LangChain,是因为微信云函数冷启动时间敏感,LangChain的依赖包太大(>15MB),而MiniAgent核心仅32KB。
6.2 关键函数实现:如何让Agent真正“懂”美妆
导购的核心是商品筛选,但直接让LLM写SQL风险极高。我们的方案是:把业务规则编码为函数,LLM只做参数提取。
def search_foundation( skin_type: str = None, price_range: str = None, undertone: str = None ) -> list[dict]: """ 根据肤质、价格、肤色基调筛选粉底液 skin_type: dry/oily/combination/sensitive price_range: low(<200)/mid(200-500)/high(>500) undertone: cool/warm/neutral """ # 1. 构建SQL查询条件(非LLM生成!) conditions = [] if skin_type: conditions.append(f"skin_type = '{skin_type}'") if price_range: if price_range == "low": conditions.append("price < 200") elif price_range == "mid": conditions.append("price BETWEEN 200 AND 500") else: conditions.append("price > 500") if undertone: conditions.append(f"undertone = '{undertone}'") # 2. 执行查询(使用CloudBase的数据库API) where_clause = " AND ".join(conditions) products = cloud_db.collection("foundations").where(where_clause).limit(5).get() # 3. 返回结构化结果(供LLM生成自然语言摘要) return [ { "name": p["name"], "brand": p["brand"], "price": p["price"], "shade_match": p.get("shade_match_score", 0), "review_summary": p.get("review_summary", "") } for p in products ]注意:skin_type、price_range、undertone这些参数,是由LLM从用户输入中提取的,但提取规则我们固化在Prompt里:
请从用户输入中提取以下字段,若未提及则留空: - skin_type: 从"干皮"、"油皮"、"混合皮"、"敏感肌"中匹配,返回英文 - price_range: 从"便宜"、"平价"→"low","中等"、"适中"→"mid","贵"、"高端"→"high" - undertone: 从"冷调"、"暖调"、"中性"匹配,返回英文这样,LLM不需要理解美妆知识,只需要做字符串匹配,准确率从72%提升到98.6%。
6.3 微信端集成:如何让Agent“活”在小程序里
微信小程序不能直接调用云函数的HTTP接口,必须用wx.cloud.callFunction。我们在云函数中封装Agent调用:
// 云函数 index.js exports.main = async (event, context) => { const { userInput, sessionId } = event; // 初始化MiniAgent(模型在冷启动时加载) const agent = new MiniAgent( new Ollama({ host: 'http://ollama-service:11434' }), [search_foundation, get_product_detail] ); try { const result = await agent.run(userInput, sessionId); return { success: true, message: result }; } catch (error) { return { success: false, error: error.message }; } };小程序端调用:
// 小程序JS wx.cloud.callFunction({ name: 'agent', data: { userInput: '我是油皮,预算300左右,想要暖调的粉底', sessionId: wx.getStorageSync('sessionId') || Date.now().toString() }, success: res => { console.log('Agent回复:', res.result.message); // 渲染到页面 } });最关键的一点:我们为每个用户生成唯一的sessionId,并存储在wx.setStorageSync中,确保对话状态在小程序前后台切换时不丢失。这比依赖服务器Session更可靠,因为微信小程序的后台进程可能随时被系统回收。
6.4 上线后的意外:LLM的“过度礼貌”如何毁掉转化率
Agent上线首周,我们发现一个诡异现象:用户点击“找粉底液”后,Agent回复非常长,充满“亲~”、“呢~”、“哦哦~”等语气词,转化率比预期低40%。日志分析发现,LLM在System Prompt中被要求“语气亲切友好”,但它把“亲切”理解成了“多用语气词”,而忽略了“简洁高效”这个更重要的商业目标。
解决方案是:在Prompt中用具体示例定义“亲切”的边界。我们把System Prompt改成:
你是一个专业的美妆顾问,回复需满足: - 开头直接给出结论,如“根据您的需求,推荐3款粉底液” - 每款产品用1句话说明核心优势,如“兰蔻持妆粉底:控油持妆12小时,油皮首选” - 禁止使用“亲~”、“呢~”等网络语气词 - 若用户未提供关键信息(如肤质),用提问方式引导,如“请问您是干皮、油皮还是混合皮?”同时,在MiniAgent._handle_response()中加入后处理:
def _post_process_response(self, text: str) -> str: # 移除连续重复的语气词 text = re.sub(r'(亲~|呢~|哦哦~)+', '', text) # 强制截断超长回复(>300字) if len(text) > 300: text = text[:297] + "..." return text.strip()调整后,平均回复长度从210字降到85字,用户点击“查看详情”按钮的比率提升了63%。
最后分享一个血泪经验:在微信小程序里,Agent的首次响应必须在3秒内返回。我们通过预热Ollama模型(冷启动时主动调用一次
ollama list)、压缩Prompt模板(去掉所有注释和空行)、以及设置timeout=2500毫秒的硬性约束,最终将P95响应时间稳定在2.1秒。慢1秒,流失率增加22%——这是微信官方公布的数据,不是我们的猜测。