手敲500行Python Agent,再用LangChain4j重写——同一个Bug让我怀疑模型2小时

0 阅读10分钟

手敲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行):

  1. 工具注册表(2个字典):
TOOL_FUNCTIONS = {}    # 工具函数本体,调用时执行
TOOL_SCHEMAS = []      # 工具的JSON Schema描述,喂给LLM
  1. 每个工具的注册(手动写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就调不了。

  1. 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
  1. 兜底机制(~20行):因为有些模型不稳定,有时走标准tool_calls,有时把调用写在文本里:
def extract_tool_calls_from_text(text: str) -> list:
    """从LLM文本中提取工具调用JSON"""
    pattern = r'{"name": "(\w+)", "arguments": (\{.*?\})}'
    # 正则提取 + JSON解析
  1. 参数名容错(~15行):LLM可能用location而不是定义里的city
def fix_args(func_name: str, func_args: dict) -> dict:
    aliases = {"get_weather": {"location": "city"}}
    # 自动映射到正确的参数名
  1. 权限控制(~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行
手动Schema75行(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。

踩坑一天,总结三条

  1. 怀疑模型之前,先怀疑自己的代码。 今天2小时的"模型不支持FC"假象,根因是Python脚本执行顺序导致tools=None。加一行DEBUG打印就能定位,比猜测模型能力高效得多。

  2. 生产级Agent必须有FC兜底机制。 不是"弱模型才需要"——任何模型的FC都可能在某些条件下不稳定(工具数量多、system prompt干扰、参数名偏差等)。extract_tool_calls_from_text这种兜底是防御性编程,跟模型强弱无关。

  3. 手敲理解底层,框架落地生产。 Python版让你看清tool_calls从意图到执行的全链路,LangChain4j版让你专注业务逻辑而不是重复造轮子。面试时讲踩坑过程,工作时用框架提效。

欢迎关注公众号「CK码农茶馆」,LLM学习路上的踩坑和实战,每周更新。有问题评论区聊,我基本都会回。