Agent不会干活?我给它加了3个工具,从FC到MCP一条线看透工具链进化
昨晚凌晨我跑通了一个能调3种工具的Agent——查天气、查数据库、读文件。跑通那一刻才真正理解一件事:LLM自己啥也干不了,它只会下工单。 你的代码才是干活的人。
这不是什么新鲜道理,但手敲代码跑通闭环后,感受完全不一样。之前看文档觉得FC挺简单——定义函数、注册tool、两轮对话,概念三步就完了。实际写的时候踩了4个坑,还有一个是中文数据库的charset问题,排查半小时才定位到根因。
今天把完整过程写出来,从FC本地调函数到MCP协议通信,一条线看透Agent工具链是怎么从"一个人干活"进化到"团队协作"的。
FC到底是怎么回事:LLM不会调函数,只会下工单
在写代码之前,先把FC的底层机制讲清楚。
LLM本身是纯文本生成模型。它不会调API、不会查数据库、不会执行代码——它能做的只有一件事:根据输入预测下一个token。Function Calling的本质是:LLM在训练时被专门微调过,学会了一种特殊输出格式——当它判断"这个问题我需要调工具才能回答"时,不输出纯文本,而是输出一个结构化的JSON意图。
这个JSON就是"工单"。比如问"北京天气怎么样",LLM不输出"北京今天晴天",而是输出:
{"name": "get_weather", "arguments": {"city": "Beijing"}}
你的代码拿到这个工单后,执行真正的API调用,拿到结果"30°C 晴 湿度24%",再喂回给LLM,它才能生成最终回答。
整个流程是两轮对话:
- 第一轮:User Message → LLM输出tool_call(意图)
- 你的代码执行:拿tool_call的参数调真实函数
- 第二轮:把函数结果作为Tool Message喂回去 → LLM生成最终文本
LangChain4j官方文档有个精辟的例子——问"475695037565的平方根是多少":
- 没有工具时,LLM直接猜"约689710"(接近但不对)
- 有squareRoot工具时,LLM输出
squareRoot(475695037565),代码执行得到精确值689706.486532,喂回去后LLM给出正确答案
这就是FC的价值——让LLM在它不擅长的领域(数学计算、实时数据、外部API)也能给出准确结果。
LangChain4j提供了两层API:
- Low Level:用ChatModel + ToolSpecification手动构建tool定义,手动执行tool_call,手动构造ToolExecutionResultMessage。灵活但啰嗦,跟OpenAI Python SDK的用法类似
- High Level:用AI Service +
@Tool注解,一个注解就能把Java方法变成工具,框架自动处理tool_call解析→方法执行→结果回传。跟Spring的声明式风格一致
我这次用的Python + OpenAI SDK,本质上就是Low Level——手动定义tools JSON、手动解析tool_calls、手动执行函数、手动构造tool message。好处是每一步都看得清楚,坏处是代码啰嗦。等Day7实战项目我会用LangChain4j High Level API重写一遍,对比两种方式的差异。
还有一个关键点:不是所有模型都支持FC,支持程度也参差不齐。LangChain4j文档里有个"Tools"列标注哪些模型支持。GLM-5.1通过硅基流动中转站支持,但有时对复杂tool定义的理解不如GPT-4o准确。description写得越清晰,LLM决策越靠谱——这是实战中最重要的经验。
第一个工具:天气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(标准协议+独立部署+服务发现)。
MCP协议的核心架构
MCP是Anthropic在2024年底发布的开放协议,全称Model Context Protocol。它的设计哲学跟FC完全不同——FC是开发者定义工具给LLM用,MCP是Server自己描述自己有什么能力。
MCP有三种角色:
- Host:发起连接的应用(比如IDE、Agent框架),管理多个Client
- Client:跟Server建立1:1连接,负责协议协商和消息转发
- Server:提供具体能力(工具、资源、Prompt模板),通过stdio或SSE跟Client通信
Host可以同时连接多个Server。比如一个Agent同时连filesystem-mcp、weather-mcp、database-mcp三个Server,每个Server独立运行独立部署。Agent不需要知道Server内部怎么实现,只要连上就能自动发现它有什么工具。
通信有两种模式:
- stdio:通过标准输入输出管道通信,适合本地进程。我的文件系统Server就是这种模式——Agent启动一个子进程运行mcp_server.py,通过stdin/stdout交换JSON-RPC消息
- SSE(Server-Sent Events):通过HTTP长连接通信,适合远程部署。Server可以跑在另一台机器上,Client通过URL连接
MCP协议的生命周期三步:
- 初始化:Client发
initialize请求,双方协商版本和能力(Client告诉Server自己支持什么,Server告诉Client自己提供什么) - 消息交换:Client发请求(
tools/list发现工具、tools/call调用工具、resources/list发现资源),Server返回结果 - 关闭:任意一方发关闭通知
跟FC对比,MCP有三个根本区别:
- 工具发现是动态的——FC的tool定义是静态JSON写死在代码里的,MCP的tool列表是运行时通过
tools/list动态发现的。Server加新工具,Client自动知道 - Server是独立进程——FC的工具函数跟Agent在同一个进程里,MCP的Server是独立进程甚至独立机器。这意味着Server可以热加载、独立升级、独立扩展
- 协议是标准化的——FC的tool定义格式各框架不一样(OpenAI一套、Anthropic一套、Google一套),MCP统一用JSON-RPC 2.0,任何语言任何框架都能对接
LangChain4j也支持MCP——通过McpToolProvider把MCP Server暴露的工具自动转成LangChain4j的ToolSpecification,无缝接入AI Service。这意味着你写一个MCP Server,既能在Python Agent里用,也能在Java Agent里用,不需要写两套tool定义。
再往上看还有两层——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模型上。所有踩坑均为真实经历。
更多推荐

所有评论(0)