Skip to content

02 · LangChain 基础

第一课直接操作 Chat Completions 的消息字典和工具定义。LangChain 提供 Python 消息类型、工具包装器和模型适配器,让常见操作有统一接口。它不会替应用决定工具权限,也不会把一次模型请求变成一个自动可信的 Agent。理解底层消息协议仍然重要:当调用失败时,你需要知道模型收到哪些工具声明、返回了什么调用、宿主实际执行了什么,以及工具结果如何回填。

本仓库的 lessons/02_langchain/main.py 是一个离线契约练习,live.py 是可选实时路径。两者都使用 LangChain 的工具和消息类型,但用途不同:前者不调用模型服务,后者会向你配置的服务发送请求。先完成离线练习,再决定是否要测试实时路径。

工具函数如何变成可描述的工具

Section titled “工具函数如何变成可描述的工具”

现有代码用 @tool 包装普通 Python 函数:

@tool
def add_numbers(left: int, right: int) -> int:
"""Add two whole numbers and return their sum."""
return left + right

这个摘录来自仓库现有 main.py。LangChain 根据函数签名和说明生成工具输入 Schema,工具名为 add_numbers,参数 left、right 是整数。工具定义可以交给聊天模型,让模型生成符合格式的调用提议。工具实现仍是本机 Python 函数,最终执行发生在调用 add_numbers.invoke(...) 的进程中。

bind_demo_tool() 创建 ChatOpenAI,再调用 model.bind_tools([add_numbers])。绑定表示将工具声明附加到模型调用配置;绑定这一步既没有发送请求,也没有执行函数。本练习传入占位 Key local-demo-no-request,只是满足客户端构造器的配置要求。源码注释明确说明 bind_tools() 不发网络请求,也不需要真实 Key。不要把占位 Key 挪到实时示例中。

从仓库根目录安装可选依赖并执行:

Terminal window
uv sync --extra exercises
uv run --extra exercises python lessons/02_langchain/main.py

run_demo() 构造两条脚本化 AIMessage:一条请求 add_numbers(left=17, right=9),另一条传入字符串 "seventeen" 代替整数。代码没有调用 bound.invoke(...),也没有调用模型的 .invoke(...);它直接把手写的 AIMessage 交给 handle_tool_call()。因此输出中的成功和错误是本地真实执行 LangChain 工具/消息契约的结果,但不是模型根据提示生成的响应。

成功路径调用 add_numbers.invoke(call["args"]),得到整数 26,再封装为 ToolMessage:

ToolMessage(
content='{"ok": true, "result": 26}',
tool_call_id="call-ok",
name="add_numbers",
)

这个消息片段依据现有代码和固定输入整理。ToolMessage 的 tool_call_id 回指 AIMessage 中 ID 为 call-ok 的工具调用;content 是发回模型的工具观察结果。失败路径会捕获工具参数验证异常,返回形如 {"ok": false, "error_type": "...", "error": "..."} 的内容,并将 ID 设为 call-bad。具体验证异常文字由安装版本产生,不应依赖一整段固定报错字符串。

脚本还打印绑定后的 Schema,成功工具响应和参数验证错误,最后明确打印:No provider request was sent; this is a local contract demonstration. 实际 Schema JSON 的字段顺序或附加描述可能受 LangChain 版本影响;应关注工具名、字段名和类型,不要把序列化顺序当协议语义。

这个离线练习覆盖了“构造工具定义”和“执行已给定工具调用”,没有覆盖模型规划、网络错误、供应商兼容性、完整对话循环或模型最终回答。它是练习工具输入输出的可复现环境,不是实时 Agent 的替代结果。

实时路径:bind_tools 之后仍有宿主循环

Section titled “实时路径:bind_tools 之后仍有宿主循环”

在本机终端设置供应商凭证和模型名后,运行:

Terminal window
export OPENAI_API_KEY='在本机终端设置你的凭证'
export OPENAI_MODEL='供应商支持的模型名称'
# 使用 DeepSeek 时按 README 配置兼容 API 地址:
export OPENAI_BASE_URL='https://api.deepseek.com'
uv run --extra exercises python lessons/02_langchain/live.py 'Use add_numbers to calculate 17 + 9.'

占位文字不是有效凭证,不要把真实 Key 粘到聊天或仓库里。live.py 从环境变量读取配置,用 ChatOpenAI(...).bind_tools([add_numbers]) 构造模型,再把 HumanMessage 作为消息列表调用 model.invoke(messages)。这次 .invoke() 才发起真实模型请求,并可能产生费用。返回的 AIMessage 会被追加到历史;没有 tool_calls 时程序返回模型文本;有调用时,宿主检查工具名、调用 add_numbers.invoke(...),再用 ToolMessage 将结果和 tool_call_id 放回消息列表,开始下一轮。

实时脚本只暴露 add_numbers,不提供文件、Shell 或网络访问工具。它最多运行 4 轮,并为模型请求设置 30 秒超时、关闭客户端自动重试。它逐个处理响应中的工具调用;未知工具返回受控错误,不调用任意函数。参数错误会回填 error_type,但这个实时分支只保留异常类型,不把异常详细文本发回模型。达到轮数限制时,脚本抛出错误而不是伪造最终答案。

离线和实时路径的核心分界是响应来源:离线 main.py 人工构造 AIMessage,用于测试工具执行及 ToolMessage;实时 live.py 通过模型适配器取得 AIMessage,由宿主执行调用并继续对话。两条路径都不能把 bind_tools() 本身理解成执行工具。

  1. 运行离线示例。**完成标准:**输出含绑定工具 Schema、成功结果中的 26、非法参数错误及“没有发送供应商请求”的说明;你能指出代码里固定构造 AIMessage 的位置。
  2. 阅读 bind_demo_tool() 和 handle_tool_call()。**完成标准:**能解释 bind_tools([add_numbers]) 声明什么,add_numbers.invoke(...) 才做什么,以及 ToolMessage.tool_call_id 为何要保留原 ID。
  3. 改变 main.py 中两条脚本化调用参数,例如改成 left=12, right=9。**完成标准:**成功结果随固定输入变为 21;无效类型仍生成失败消息。不要声称这是模型根据新提示作出的决定。
  4. 加一个 multiply_numbers(left: int, right: int) 工具,并在 run_demo() 中手写一条相应 AIMessage。**完成标准:**工具描述能出现在绑定 Schema 中,宿主只允许明确支持的名称,工具结果有匹配调用 ID。此练习修改仓库代码前先保留原文件或在自己的分支操作;本教程本身不要求你改动源文件。
  5. 若拥有凭证且愿意产生一次真实请求,运行 live.py 的算式提示。**完成标准:**最终数值应为 26;若模型提出工具调用,终端还会显示 round 1: ... tool request(s) 一类日志。bind_tools 不强制模型调用工具,所以只看到最终回答也可能是正常路径。没有凭证时跳过,不需要提交 Key 或向他人索取。
  6. 在实时提示中加入无关问题,比较模型直接回答和请求工具时的 trace。**完成标准:**能指出 tool_calls 为空时循环为何返回,以及参数校验出错时如何转成工具观察消息。
  • No module named langchain_core 或 langchain_openai:未安装可选练习依赖。回到仓库根目录运行 uv sync --extra exercises,再用同一 uv run --extra exercises 执行。
  • 离线脚本打印出工具 Schema,但看不到模型行为:这是设计结果。main.py 没发模型请求;Schema 来源于绑定对象,工具调用来源于源码构造的 AIMessage。
  • Set OPENAI_API_KEY and OPENAI_MODEL in your shell:实时脚本当前进程未收到变量。检查在当前终端设置并在同一终端执行;不要打印 Key 来排查。
  • 401、连接或模型错误:检查凭证、模型名、供应商端点和网络。DeepSeek 路径依赖 OPENAI_BASE_URL=https://api.deepseek.com;本练习只在真实运行前对当前供应商兼容性做验证。
  • 模型直接回答,没有工具日志:bind_tools 并不强制模型调用工具,模型可以直接回答。提示明确要求使用 add_numbers,并确认供应商支持工具调用。
  • 参数类型错误:工具输入 Schema 不代表所有响应均合格。检查模型给的 args 和工具验证错误;保留受控失败路径,不要用宽松转换把任意字符串默默当作整数。
  • 达到 4 轮限制:检查每轮消息和工具结果,任务可能持续请求工具。不要直接删掉限制;先确认工具结果能帮助模型结束,并根据预算定义新的停止规则。

@tool 和 bind_tools 减少了手动拼接工具 Schema 的工作,消息对象让 AIMessage 与 ToolMessage 的语义更明确。排查底层问题时仍要看供应商协议:工具是否传入请求、模型返回的工具调用字段、调用 ID 与工具消息如何对应。还需单独实现实际执行策略、授权、超时、资源限制和副作用控制。

Schema 校验只回答输入是否符合结构约束。例如整数参数拒绝 "seventeen",不代表数值本身正确,也不证明调用符合用户授权。检索器、结构化输出或其他框架抽象同样不会自动保证来源准确或内容真实。遇到接口变化,核对仓库锁定的实际版本和当前官方文档,不要把旧教程 API 当成当前事实。

本课只验证 Python 路径。网站在浏览器中运行的 Tool Calling 实验使用 JavaScript 直接请求供应商;它不是 LangChain Python,也没有执行这些 Python 工具。

问:bind_tools()、工具调用提议和工具执行分别在哪里发生?

bind_tools() 在应用侧把工具定义附加到模型调用配置;模型收到定义后可以在响应中提出工具调用;宿主应用解析响应并调用本地工具实现。绑定不等于执行,模型也没有因声明而取得应用权限。

问:离线 LangChain 示例为什么不算模型 Agent?

它虽然用 LangChain 的真实工具和消息类型,但 AIMessage 是源码手写的,bind_tools() 和工具执行之间没有模型网络请求。示例验证工具 Schema、参数错误和 ToolMessage 合同,不验证模型的规划或最终回答。

问:工具调用结束后为什么仍要理解原始消息协议?

框架适配器最终仍需把工具描述、assistant 调用和工具结果转换成供应商能理解的请求。理解角色、调用 ID 和结果消息能定位格式或适配器故障,并帮助确认宿主是否执行了模型真正请求的工具。

问:参数通过 Schema 校验能说明什么,不能说明什么?

它说明数据符合指定的字段和类型约束。它不能证明数据为真、调用合乎授权、操作安全或工具结果正确。授权和副作用控制必须由宿主另行实现。