❓ 疑问一:AI 是一定会调用工具,还是会直接生成代码?
答案是:全看 AI 自己觉得需不需要!它拥有完全的"自主决定权"。
在传统的编程里,代码写了 if 就走分支,写了 requests.get 就一定发请求。但在 Agent 的世界里,coder_llm_with_tools.invoke(messages) 这一行代码,就像是把一份**"开卷考试的考卷"和一部"能上网的手机(工具)"**同时放在了 Claude 面前。
- 场景 A:闭眼作答(直接生成代码)
假设用户的需求是:"写一个计算斐波那契数列的函数"。
Claude 看到这个需求,心里想:"这太简单了,我闭着眼睛都能写,根本不需要查资料。"
于是,它不会输出任何工具调用指令,而是直接老老实实地输出一段带有 **<code_block>**的纯文本。
我们的路由器(Router)看到它没用工具,就会直接把它送去 Tester 节点。
- 场景 B:翻书作答(调用工具)
假设用户的需求是:"使用最新的 LangGraph 0.3 版本 API 写一个状态图"。
Claude 看到后,心里一惊:"我的训练数据只到 2024 年,我不知道 0.3 版本长什么样。"
这时候,它就会主动选择**"求助"**。它会输出一个 ToolCall****指令,要求使用 search_web****工具去搜"LangGraph 0.3 API"。
路由器发现它求助了,就会把它送到 Tools 节点去查资料。
💡 总结: 我们通过 bind_tools 只是给了它"查资料的能力和权利",但绝对不强制它必须查。这就是所谓"智能"的体现------它会根据自己掌握知识的边界,动态决定要不要使用工具。
❓ 疑问二:正则表达式(Regex)到底是怎么提取代码的?
正则表达式(Regular Expression,简称 Regex)就像是给文本做的一台"高精度外科手术"。大模型往往很啰嗦,可能会说:"好的老板,这是您要的代码:\n <code_block>def test(): pass</code_block> \n 希望您满意!"
我们需要把废话剔除,只精准地把尖括号中间的代码"抠"出来。
让我们彻底解剖这行代码:
code_match = re.search(r'<code_block>(.*?)</code_block>', ai_text, re.DOTALL)
1. 拆解匹配规则 r'<code_block>(.*?)</code_block>'
- r'...':前面的
r代表"原始字符串"(Raw string),意思是告诉 Python 忽略转义字符,里面写啥就是啥。 - **<code_block>**和 </code_block>:这就像是两个"定位锚点"。告诉正则引擎:"你先给我找到左边这个词,再找到右边这个词。"
- (.?)***(这是最核心的魔法):
-
- .****(点):代表**"匹配任何单个字符"**(字母、数字、标点都可以)。
- *****(星号) :代表**"重复前面的字符 0 次或无数次"**。所以
.*连起来就是"匹配中间的任意一长串内容"。 - ?****(问号,非贪婪模式) :如果没有这个问号,正则会非常"贪婪",它会从第一个
<code_block>一直匹配到整篇文章最后一个</code_block>(如果文中有好几个块就全乱套了)。加了?就是告诉它:"只要遇到第一个</code_block>,你就赶紧停下来!" - ()****(括号,捕获组) :圆括号的意思是"提取"。虽然我们匹配了整个
<code_block>...内容...</code_block>,但我只想要括号里面的"内容",不想要两边的标签。这叫捕获组 1。
2. 为什么要加 re.DOTALL 标志?
这是一个极其容易踩坑的地方!
在正则表达式的默认规则里,刚才说的那个神奇的 .(点),唯独不能匹配一种东西:换行符( \n**)**。
但是,我们提取的 Python 代码通常是有十几行的,中间全是换行符!如果不加这个参数,匹配会在第一行结束时直接断掉报错。
加上 re.DOTALL,就是赋予了 . 特权:"从现在起,连换行符你也给我统统匹配进去!"
3. 如何拿到提取的结果?
if code_match:
# group(0) 会返回完整的:<code_block>def a(): pass</code_block>
# group(1) 只会返回括号里捕获的纯净代码:def a(): pass
pure_code = code_match.group(1).strip() # strip() 用于去首尾的空格和空行
❓ 疑问三:last_message到底是个什么对象?
当代码执行完 response = coder_llm_with_tools.invoke(messages) 时,返回的 response(也就是我们存进状态里的 last_message),并不是一个普通的字符串,而是 LangChain 框架定义的一个对象(Object) ,叫做 AIMessage。
这个 AIMessage 对象身上,有两个极其重要的属性(字段):
- content:用来存放 AI 说的普通文本内容(比如写好的代码、或者聊天的废话)。
- tool_calls:这是一个列表(List) ,专门用来存放 AI 发出的工具调用请求。
🎬 场景还原:两种情况下的内存快照
让我们看看在你刚才提问的两种情况下,这个对象在内存里长什么样。
情况 A:AI 决定直接写代码(不查资料)
当 AI 觉得题目太简单,直接输出带 <code_block> 的代码时。
这个 AIMessage 对象的内部结构是这样的:
AIMessage(
content="好的,这是代码:\n<code_block>def test(): pass</code_block>",
tool_calls=[], # <--- 注意看这里!列表是空的!
# ... 其他属性忽略
)
此时,路由器执行到 if last_message.tool_calls:。
在 Python 中,一个空的列表 **[]**会被当成 False。所以 if 条件不成立,代码不会进入 Tools 节点,而是顺理成章地走向了 Tester 节点。
情况 B:AI 决定求助(调用 search_web)
当 AI 遇到"LangGraph 0.3 API"这种知识盲区时,由于我们用 bind_tools 提前告诉过它"你有工具可用",它就会在底层触发一个特殊的生成模式。
这时候,生成的 AIMessage 对象结构变了:
AIMessage(
content="", # <--- 注意!通常文本内容是空的,因为它没在说话,而在按按钮。
tool_calls=[ # <--- 核心魔法在这里!列表里多了一个字典!
{
"name": "search_web", # 它想要调用的工具名称
"args": {"query": "LangGraph 0.3 API"}, # 它自动提取的搜索关键字
"id": "call_abc123xyz" # 这次调用的唯一流水号
}
],
# ... 其他属性忽略
)
此时,路由器再次执行到 if last_message.tool_calls:。
这次,列表里有东西了!在 Python 中,非空列表会被当成 True。
于是 if 条件成立!程序立刻打印 [Router] 决策:Coder 需要查资料...,并把流程导向了 Tools 节点。
💡 为什么需要那个特殊的 id 字段?(进阶思考)
你可能会注意到上面那个 call_abc123xyz。这在微服务通信里叫"请求溯源 ID"。
当我们的 Tools 节点去百度搜完结果后,我们要把结果发回给大模型。大模型怎么知道这个结果对应的是它哪一次的提问呢?
所以在 Tools 节点里,我们必须把这个 ID 抄下来,连同搜索结果一起打包成一个 ToolMessage 发给它:
ToolMessage(
content="搜索结果:LangGraph 0.3 引入了新的...",
tool_call_id="call_abc123xyz" # <--- 拿着单号去交差
)
大模型一看到这个单号对上了,就会恍然大悟:"哦!这是我刚才查的资料结果回来了!我现在可以开始写代码了!"
🔬 代码逻辑深度拆解
在这个十字路口,路由器拿到了 last_message(也就是 AI 刚刚在 coder_node 里吐出来的那个对象)。
第一道关卡:hasattr(last_message, "tool_calls")
- 这是什么? 这是 Python 中的内置函数,意思是:"查一下
last_message这个对象身上,有没有一个叫做tool_calls的属性(字段)?" - 为什么要这么写(防御性编程)? 在 LangChain 的世界里,消息有很多种类型:
HumanMessage(人类说的)、SystemMessage(系统设定的)、以及AIMessage(AI 说的)。
通常情况下,last_message 肯定是个 AIMessage。但作为严谨的工程师,我们要防患于未然:万一系统出了 Bug,传过来一个根本没有 tool_calls 属性的奇怪对象,如果你直接写 if last_message.tool_calls:,Python 解释器会立刻原地爆炸,抛出 AttributeError 导致整个微服务宕机。
所以,先礼后兵。先问一句:"兄弟,你身上有这个口袋吗?"
第二道关卡:and last_message.tool_calls
- 这是什么? 只有当第一道关卡通过(它确实有这个口袋)时,程序才会执行这半句。这半句的意思是:"既然你有这个口袋,你把口袋翻开,里面有东西吗?"
- 底层逻辑:
就像我们上节课"抓包"看到的:
-
- 如果 AI 决定直接写代码,这个口袋是个空列表
[]。在 Python 的if语句中,空列表等同于 False。条件不成立,继续往下走(去测代码)。 - 如果 AI 决定用
search_web,这个口袋里就会有一个字典[{'name': 'search_web', ...}]。非空列表等同于 True。条件成立,立刻返回"tools",把流程拐进工具节点!
- 如果 AI 决定直接写代码,这个口袋是个空列表