手敲500行Python Agent,再用LangChain4j重写——同一个Bug让我怀疑模型2小时
昨天花了一整天手敲一个5工具Agent(天气查询、MySQL查询、计算器、文件读写),用Python从零写agent_loop、tool_calls解析、权限控制、兜底机制……500行代码,每个细节都是透明的。
然后用LangChain4j重写同一个Agent,200行代码,@Tool注解自动生成Schema,AiServices.builder()一行搞定Agent循环。
结果呢?Python版跑不通,LangChain4j版秒通。
但我花了2小时怀疑GLM-5.1不支持Function Calling,最后发现——Bug不在模型,在代码的执行顺序。
这篇文章把整个踩坑过程、两版代码的核心差异、以及"手敲理解底层 vs 框架落地生产"的思考全部写出来。如果你也在做Agent开发,这个Bug你大概率也会踩。
踩了2小时的坑:不是模型的问题,是代码的执行顺序
先说这个最经典的Bug。
我手敲的Python版Agent,用了SiliconFlow上的GLM-5.1模型,5个工具定义都写好了。运行测试时,LLM返回的tool_calls字段始终为None——它把工具调用写在了文本内容里,而不是走标准的Function Calling通道。
一开始我怀疑GLM-5.1不支持多工具的FC。然后我用单独的Python脚本直接调API,5个工具定义一模一样——tool_calls完美返回!模型没问题。
又怀疑tool_choice="auto"参数导致LLM犹豫。去掉后还是不行。
又怀疑system prompt影响了FC行为。去掉system prompt后还是不行。
又怀疑TOOL_SCHEMAS结构有问题。手动构造完全相同的Schema直接调API——tool_calls正确返回。
折腾了2小时,最后加了一行DEBUG打印:
print(f"DEBUG tool_calls={msg.tool_calls}, content={msg.content}")
结果:tool_calls=None,LLM确实没走FC。
再加了一行启动检查:
print(f"[启动检查] TOOL_SCHEMAS数量: {len(TOOL_SCHEMAS)}")
输出:TOOL_SCHEMAS数量: 0
根因找到了——工具注册代码排在if __name__之后,执行测试时TOOL_SCHEMAS还是空列表!
tools=TOOL_SCHEMAS if TOOL_SCHEMAS else None 变成了 tools=None,等于没传工具给API,LLM当然无法走标准FC,只能把调用写在文本里。
修复:把工具注册代码移到if __name__之前,问题秒解。
这个Bug的本质:Python是脚本语言,按行从上到下执行。如果你的数据初始化代码排在入口函数之后,运行入口函数时数据还没填充。
说白了就是——怀疑模型之前,先怀疑自己的代码。
Python版:500行代码,每个细节都透明
Python版的Agent架构是ReAct循环——用户提问→LLM思考是否调工具→调工具→喂回结果→LLM继续思考→直到给出最终回答。
核心代码结构(544行):
- 工具注册表(2个字典):
TOOL_FUNCTIONS = {} # 工具函数本体,调用时执行
TOOL_SCHEMAS = [] # 工具的JSON Schema描述,喂给LLM
- 每个工具的注册(手动写Schema + append):
# 天气查询工具
TOOL_FUNCTIONS["get_weather"] = get_weather # 函数本体
TOOL_SCHEMAS.append({ # Schema定义(~15行)
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的当前天气信息",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名称"}
},
"required": ["city"]
}
}
})
5个工具×15行Schema = 75行纯手动Schema定义。每次加一个新工具,你都要手动写JSON Schema、append到列表、确保参数名和函数签名一致。漏一个字段、写错一个参数名,LLM就调不了。
- agent_loop主循环(~50行):
for i in range(max_iterations):
response = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOL_SCHEMAS,
)
msg = response.choices[0].message
if msg.tool_calls:
# 执行工具 → 喂回结果 → 继续循环
for tool_call in msg.tool_calls:
result = TOOL_FUNCTIONS[func_name](**func_args)
messages.append({"role": "tool", "content": str(result)})
else:
# 没有工具调用,返回最终回答
return msg.content
- 兜底机制(~20行):因为有些模型不稳定,有时走标准tool_calls,有时把调用写在文本里:
def extract_tool_calls_from_text(text: str) -> list:
"""从LLM文本中提取工具调用JSON"""
pattern = r'{"name": "(\w+)", "arguments": (\{.*?\})}'
# 正则提取 + JSON解析
- 参数名容错(~15行):LLM可能用
location而不是定义里的city:
def fix_args(func_name: str, func_args: dict) -> dict:
aliases = {"get_weather": {"location": "city"}}
# 自动映射到正确的参数名
- 权限控制(~30行):三层权限——auto(天气/计算器) | confirm(DB/读文件) | confirm_always(写文件)
总代码:544行。其中75行是手动Schema定义,50行是agent循环,20行是兜底解析,15行是参数容错,30行是权限控制。
这500行代码的每一个细节你都看得见、能调试、能改。但代价是——你手动维护的东西越多,出Bug的概率越高。今天的执行顺序Bug就是典型案例。
LangChain4j版:200行代码,框架替你干了75%的活
用LangChain4j重写同一个5工具Agent,核心代码只有两个文件。
工具定义——一个@Tool注解搞定:
@Tool("查询指定城市的当前天气信息,包括温度、湿度、风速等。")
public String getWeather(String city) {
// 直接写业务逻辑,不用手动写JSON Schema
// 框架自动从注解+方法签名生成Schema
}
@Tool("查询llm_learn学习数据库。只允许SELECT语句。")
public String queryMysql(String sql) {
// 同样,注解搞定,0行Schema
}
对比Python版的75行手动Schema定义——LangChain4j版0行。 框架从@Tool注解的描述 + 方法参数的类型和名称,自动生成跟Python版一模一样的JSON Schema。
Agent循环——一行build搞定:
SmartAssistant assistant = AiServices.builder(SmartAssistant.class)
.chatModel(chatModel)
.tools(new SmartAssistantTools()) // 注入工具对象
.build();
对比Python版的50行手写agent_loop——LangChain4j版1行。 框架内部自动处理:LLM返回tool_calls → 执行工具 → 喂回结果 → 继续对话 → 直到最终回答。整个ReAct循环你一行代码都不用写。
FC兜底——框架内置处理:
对比Python版的20行extract_tool_calls_from_text——LangChain4j版0行。 框架已经内置了非标准FC的处理逻辑,你不用关心。
参数容错——框架自动校验:
对比Python版的15行fix_args——LangChain4j版0行。 框架从方法签名自动推断参数名和类型,LLM传错参数名时框架帮你校验。
执行顺序Bug——不存在:
对比Python版的2小时踩坑——LangChain4j版0风险。 工具注册是框架在build时自动扫描@Tool注解完成的,不存在"数据还没初始化就执行"的问题。
总代码:SmartAssistantTools.java(242行)+ SmartAssistantExample.java(112行)= 354行。但真正"你自己写的逻辑"只有工具的业务代码(~100行)+ 测试代码(~30行)。
框架替你干了Schema生成、Agent循环、FC兜底、参数校验——这些在Python版里你要手动维护的130行代码,在LangChain4j版里完全不需要。
数据对比:同一套工具,两版代码的差距
| 维度 | Python手敲版 | LangChain4j版 |
|---|---|---|
| 总代码行数 | 544行 | 354行 |
| 手动Schema | 75行(5个×15行) | 0行(@Tool注解自动生成) |
| Agent循环 | 50行(手写agent_loop) | 1行(AiServices.builder()) |
| FC兜底 | 20行(extract_tool_calls_from_text) | 0行(框架内置) |
| 参数容错 | 15行(fix_args别名映射) | 0行(框架自动校验) |
| 权限控制 | 30行(三层权限) | 写在@Tool方法内(相同逻辑) |
| 执行顺序Bug | 踩了2小时 | 不存在(框架托管) |
| 测试结果 | 全4项通过 | 全4项通过 |
差距不是代码行数——是你手动维护的东西越少,出错的空间就越小。
那为什么还要手敲?
你可能会问:既然LangChain4j这么方便,直接用框架就行了,何必花一整天手敲500行Python?
答案很简单:手敲让你理解底层机制,框架让你落地生产。两个能力缺一不可。
如果你只知道AiServices.builder(),面试官问"Agent的tool_calls是怎么从意图到执行的全链路"你就答不上来。你不知道LLM返回的tool_calls字段长什么样,不知道非标准FC怎么兜底,不知道参数名偏差怎么处理。
手敲500行Python,你踩过的每一个坑都是面试素材:
- "GLM-5.1的FC不稳定,有时走标准通道有时把调用写在文本里,所以我写了兜底解析"——这比"框架自动处理"更能体现你理解问题
- "LLM可能用
location而不是city,我做了参数别名映射"——这比"框架自动校验"更能证明你遇到过真实场景 - "Python脚本执行顺序导致工具列表为空"——这比"框架不存在此问题"更能说明你排查问题的能力
手敲的价值不在代码本身,在踩坑的过程。
GLM-5.1的FC到底稳不稳?
实测数据说话——同一个模型、同一个API、同样的5个工具定义,直接调API时tool_calls完美返回,但在Python脚本里跑时tool_calls=None。
根因不是模型不稳定,是代码传给API的tools参数为None(因为执行顺序Bug导致工具列表还没填充)。API收到tools=None就等于"你没给我工具定义",LLM当然没法走FC通道。
但这个排查过程本身也揭示了一个重要事实:LLM的FC行为确实有非确定性的一面。 我在排查过程中多次直接调API测试,5个工具时tool_calls有时正确返回,有时LLM把调用写在文本里。1个工具(weather_agent.py)时则100%稳定走FC。
所以Python版加的extract_tool_calls_from_text兜底机制不是"弱模型才需要"——是生产级Agent必须有的防御机制。任何模型的FC都可能在某些条件下不稳定,兜底解析确保你的Agent不会因为模型偶尔"不走FC"就完全瘫痪。
LangChain4j框架内部也有类似的兜底处理,只是你看不到。这就是框架的价值——它把你看不见但必须有的东西替你做好了。
项目架构:一个llm-learn项目,Python和Java共存
这个Agent对比实验是在我的llm-learn项目里做的,项目结构挺有意思——Python和Java代码共存,同一套API配置,同一套工具逻辑:
llm-learn-app/
├── src/main/python/agent/ # Python版Agent(手敲)
│ ├── agent_assistant.py # 544行,手写完整逻辑
│ ├── weather_agent.py # 1个工具的简单版(参考对比)
│ └── article_agent_comparison.md # 这篇文章
├── src/main/java/.../agents/
│ ├── smart_assistant/ # LangChain4j版Agent
│ │ ├── SmartAssistantTools.java # 242行,@Tool注解工具定义
│ │ ├── SmartAssistantExample.java # 112行,AI Service + 测试
│ ├── code_review_workflow/ # LangChain4j多Agent工作流(之前写的)
├── pom.xml # Maven依赖(LangChain4j 1.15.0)
└── data/llm_learn.db # SQLite数据库
关键依赖配置:
Python版——依赖极简:
# 只需要两个库
from openai import OpenAI # API客户端
import requests # 天气API调用
LangChain4j版——框架依赖:
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
<version>1.15.0</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
<version>1.15.0</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-agentic</artifactId>
<version>1.15.0-beta25</version>
</dependency>
两者用的是同一个SiliconFlow API + 同一个GLM-5.1模型,只是调用方式不同——Python直接用OpenAI SDK,Java用LangChain4j封装的OpenAiChatModel。
踩坑一天,总结三条
-
怀疑模型之前,先怀疑自己的代码。 今天2小时的"模型不支持FC"假象,根因是Python脚本执行顺序导致
tools=None。加一行DEBUG打印就能定位,比猜测模型能力高效得多。 -
生产级Agent必须有FC兜底机制。 不是"弱模型才需要"——任何模型的FC都可能在某些条件下不稳定(工具数量多、system prompt干扰、参数名偏差等)。
extract_tool_calls_from_text这种兜底是防御性编程,跟模型强弱无关。 -
手敲理解底层,框架落地生产。 Python版让你看清tool_calls从意图到执行的全链路,LangChain4j版让你专注业务逻辑而不是重复造轮子。面试时讲踩坑过程,工作时用框架提效。
欢迎关注公众号「CK码农茶馆」,LLM学习路上的踩坑和实战,每周更新。有问题评论区聊,我基本都会回。