Agent不会干活?我给它加了3个工具,从FC到MCP一条线看透工具链进化
昨晚凌晨我跑通了一个能调3种工具的Agent——查天气、查数据库、读文件。跑通那一刻才真正理解一件事:LLM自己啥也干不了,它只会下工单。 你的代码才是干活的人。
这不是什么新鲜道理,但手敲代码跑通闭环后,感受完全不一样。之前看文档觉得FC挺简单——定义函数、注册tool、两轮对话,概念三步就完了。实际写的时候踩了4个坑,还有一个是中文数据库的charset问题,排查半小时才定位到根因。
今天把完整过程写出来,从FC本地调函数到MCP协议通信,一条线看透Agent工具链是怎么从"一个人干活"进化到"团队协作"的。
第一个工具:天气API,跑通FC闭环
工具链的起点是Function Calling。原理我之前学过——LLM不调用函数,它只输出tool_calls意图,你的代码拿到意图执行后喂回去,LLM才生成最终回答。两轮对话,LLM只决定"调什么、传什么参数"。
这次我把它跑通了。用wttr.in的免费天气API(不用API Key,curl直接用),写了个get_weather函数,注册成FC tool,让LLM决定什么时候调:
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的当前天气信息,包括温度、天气描述和湿度",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,如Beijing、Shanghai、New York"
}
},
"required": ["city"]
}
}
}]
问"北京今天天气怎么样",LLM输出get_weather({'city': 'Beijing'}),代码执行拿到"30°C 晴 湿度24%",喂回去,LLM生成"北京今天晴,30度,湿度较低比较干燥"。
问"帮我写个快排"——LLM直接回答,不调工具。判断正确。
这里有个关键设计点:description字段是LLM决策的唯一依据。写清楚比写花哨重要。如果你只写"查天气"三个字,LLM可能把"帮我查一下明天穿什么"也理解成要调天气工具。写成"查询指定城市的当前天气信息",它就知道什么时候该调什么时候不该调。
第二个工具:MySQL查询,踩了中文charset的坑
天气API跑通后,第二个工具是让Agent查MySQL数据库。思路一样——定义query_database函数,注册成tool,LLM生成SQL,代码执行返回结果。
但这里我踩了一个特别隐蔽的坑。
Agent问"工程部有几个员工?平均薪资多少?",LLM生成的SQL完全正确:SELECT COUNT(*) FROM employees WHERE department = '工程部'。但返回结果是0条——"工程部"匹配不到。
排查过程:直接在Docker里用mysql命令行查,3条数据都在。Python连数据库查,0条。说明不是数据的问题,是连接层的问题。
最后定位到根因:Docker exec插入中文数据时,MySQL client charset默认是latin1,导致中文被双重UTF-8编码。数据库"看起来"有数据,HEX值却是损坏的——正确的"工程部"应该是E5B7A5E7A88BE983A8,实际存的是C3A5C2B7C2A5C3A7C2A8E280B9C3A9C692C2A8。
修复两处:
- 删表重建,插入前执行
SET NAMES utf8mb4; - Python连接加
charset="utf8mb4"参数
conn = mysql.connector.connect(
host="localhost", port=3306,
user="root", password="llm_learn_2026",
database="llm_learn",
charset="utf8mb4" # 不加这句,中文WHERE匹配不到
)
这个坑在Agent查数据库时特别隐蔽——SELECT *能查到所有数据,但WHERE条件匹配不到中文值。如果你在做Agent+数据库的项目,记住这条:中文数据库必须显式指定charset=utf8mb4,从连接到插入到查询全程统一。
另外还有个安全设计值得一提——query_database做了白名单校验,只允许SELECT语句:
if not re.match(r'^\s*SELECT\s', sql, re.IGNORECASE):
return "错误:只允许SELECT查询,禁止INSERT/UPDATE/DELETE/DROP"
sql = sql.split(';')[0].strip() # 截断分号防注入
LLM生成的SQL你没法完全信任。万一它生成个DROP TABLE,没有这层校验就完了。这是Agent安全的第一道防线——工具函数自己做输入校验,不指望LLM自觉。
第三个工具:MCP Server,自描述才是真进化
天气和数据库两个工具都是FC模式——你自己写tool定义、自己写tool函数、自己写tool_map映射。三个"自己写"意味着三处硬编码。换个工具就要改代码。
MCP协议的核心优势是Server自描述。你不需要硬编码tool列表——Client连上Server后自动发现有哪些工具、参数是什么、返回什么格式。
我用Python的FastMCP库写了一个文件系统Server,两个工具:list_files(列出目录文件)和read_file(读取文件内容)。Server代码很简洁:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("filesystem-server")
@mcp.tool()
def list_files(directory: str) -> str:
"""列出指定目录下的所有文件和子目录名称"""
# ...实现省略
@mcp.tool()
def read_file(filepath: str) -> str:
"""读取指定文件的文本内容,限制返回前500行"""
# ...实现省略
mcp.run(transport="stdio")
@mcp.tool()注解注册工具,函数名、参数名、docstring自动打包成MCP的tool schema。transport="stdio"意味着Agent通过标准输入输出管道跟Server通信,不需要开HTTP端口。
Client连接Server后,日志直接告诉我们发现了什么:
[MCP发现] Server暴露了 2 个工具:
- list_files: 列出指定目录下的所有文件和子目录名称
- read_file: 读取指定文件的文本内容,限制返回前500行
这就是"自描述"——Client不需要提前知道Server有什么工具,连上就知道了。
调用方式也不同。FC是本地函数调用(tool_map[func_name](**func_args)),MCP是协议通信——Client把请求通过stdio管道发给Server,Server执行后返回结果。Server是独立进程,可以部署在不同机器上,可以热加载新工具,Client不需要改代码。
跑的时候问了两个问题:"列出agent目录下有哪些文件"和"读取weather_agent.py的内容"。LLM自动选择调list_files还是read_file,参数传得也对。整个流程跟FC一样是两轮对话闭环,但工具发现和调用方式完全不同。
FC到MCP:一条进化线
把三种工具放在一起看,进化路径很清晰:
| 维度 | FC本地调用 | MCP协议通信 |
|---|---|---|
| 工具发现 | 硬编码tool定义 | Server自描述 |
| 调用方式 | 本地函数调用 | stdio/SSE协议 |
| 扩展性 | 加工具要改代码 | Server热加载 |
| 部署 | 同进程 | 独立进程/独立机器 |
| 安全 | 工具函数自校验 | Server做校验 |
FC是"一个人干活"——你定义工具、写实现、做映射,全在同一个进程里。MCP是"团队协作"——Server专注提供能力(查文件、查数据库、调API),Client专注决策(LLM决定什么时候调什么),两者通过标准协议解耦。
Java直觉:FC像Spring的@RequestMapping(声明路由+同进程调用),MCP像微服务的RPC(标准协议+独立部署+服务发现)。
再往上看还有两层——Skills(可复用+可分享+可热加载的能力包,像Spring Starter)和A2A(Agent之间的协作协议,像服务间的消息队列)。四层进化线:FC→MCP→Skills→A2A,从被动工具到主动协作。
四个踩坑总结
| 坑 | 根因 | 怎么解决的 |
|---|---|---|
| 中文WHERE匹配不到 | Docker exec charset=latin1导致双重编码 | SET NAMES utf8mb4 + Python连接加charset |
| LLM第二轮用文本模拟调函数 | 第二轮create没传tools参数 | 加tools=tools |
| Tool description太模糊 | LLM乱调工具 | description写清楚触发条件和参数含义 |
| Docker exec插中文数据损坏 | client charset默认latin1 | 插入前SET NAMES utf8mb4 |
第二个坑最蠢——LLM在第二轮调用时没被传入tools参数,所以它"自作主张"用文本格式模拟调函数,输出了一个不规范的<function_call>标签。修复就是第二轮create也传tools=tools。
Agent工具链的核心原则我总结三条:描述要精确(LLM靠description决策)、安全要自校验(工具函数自己防注入防越权)、协议要解耦(MCP的Server自描述比FC的硬编码更可扩展)。
下次会写Agent安全与容错——权限控制、人类确认、沙箱、输入校验、重试机制。这是工具链的另一半:工具能干活了,但怎么确保它不干坏事?有问题评论区聊。
本文基于LangChain4j Week5 Day5实战,代码跑在GLM-5模型上。所有踩坑均为真实经历。