(八)让模型提出工具调用
1. 工具调用先是一条请求
现在让聊天模型自己决定何时查笔记。程序把工具名称、用途和参数结构交给模型;模型返回 search_notes 及 query,运行程序调用对应 Python 函数,再用 ToolMessage 把结果交回模型。
@tool 从 langchain.tools 导入,将函数及说明整理成工具。ToolRuntime[AgentContext] 提供本次运行的依赖;其中的 owner 由程序传入,模型看到的参数结构只有 query。
第三章的装饰器会将一个函数交给包装层;这里也一样,装饰后的名称保存的是工具对象,框架从函数名取得工具名,从文档字符串取得用途,从类型注解取得参数结构。ToolRuntime[AgentContext] 中的方括号是在说明上下文类型,真正的 Alice 身份和索引对象在调用时另行传入。
# 对照完整文件阅读这一段;此处保留当前步骤使用的函数或类型。
@dataclass
class AgentContext:
owner: str
index: NoteIndex# 对照完整文件阅读这一段;此处保留当前步骤使用的函数或类型。
@tool
def search_notes(query: str, runtime: ToolRuntime[AgentContext]) -> str:
"""搜索当前用户能阅读的笔记,返回文本块、来源和相似度。"""
# owner 来自这次运行注入的身份,不在模型填写的工具参数中。
hits = runtime.context.index.search(query, runtime.context.owner)
return json.dumps({"hits": hits}, ensure_ascii=False)dataclass 的用途与第四章相同:把相关字段放进一个明确的上下文对象。runtime.context.index 是可用的检索对象,runtime.context.owner 决定能够读取谁的资料。工具说明告诉模型这项函数能做什么,类型注解说明 query 应是字符串。
2. 先手动展开一次工具消息
先暂时去掉上下文注入,只登记一个返回切片说明的工具 find_note,把一次模型输出写成 AIMessage。这样可以单独看清“请求写了什么”和“哪一行真正调用了函数”。
"""展开一次工具请求:已有消息 → 请求 → 执行 → 返回结果。"""
from langchain.tools import tool
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
@tool
def find_note(query: str) -> str:
"""查找练习笔记中关于切片的说明。"""
return "切片共享原数组;需要独立修改时先 copy()。"
# 先固定一次模型输出,单独观察调度;真正模型的选择过程交给 create_agent。
messages = [HumanMessage(content="NumPy 切片怎样复制?")]
request = AIMessage(content="", tool_calls=[{
"name": "find_note", "args": {"query": messages[0].content},
"id": "lookup-1", "type": "tool_call",
}])
messages.append(request)
print("调用前:", [message.type for message in messages])
# 只从注册表查找工具;把完整调用交给 invoke,结果会带回调用编号。
registered = {"find_note": find_note}
call = request.tool_calls[0]
result = registered[call["name"]].invoke(call)
assert isinstance(result, ToolMessage) and result.tool_call_id == "lookup-1"
messages.append(result)
print("执行后:", [message.type for message in messages], result.content)
# 第二次模型调用要收到这份新列表,才能看到本轮工具结果。
assert [message.type for message in messages] == ["human", "ai", "tool"]messages 最初只有一条 human 消息。加入 request 后有两条,但还没有查到材料;registered[call["name"]].invoke(call) 才找到工具并执行。这里传入完整工具调用,返回的 ToolMessage 会携带 tool_call_id="lookup-1",与请求的 id 对应。若只向工具传普通参数字典,则得到普通工具返回值;完整调用额外提供了消息关联信息。
把 ToolMessage 加进 messages 后,模型下一次调用才会读到结果。第一条 AIMessage 的 content 可以为空,因为这次响应要交付的是 tool_calls。工具请求与面向用户的文字回答,保存在不同字段里。
3. 交给 Agent 完成同一轮调度
配套 build_agent 使用 langchain.agents.create_agent,把模型、工具和上下文类型组合起来。默认 ReplayToolModel 按固定脚本生成工具调用,再用工具结果构造回答;真正的工具函数由 LangChain 执行。
# 对照完整文件阅读这一段;此处保留当前步骤使用的函数或类型。
def build_agent(model=None, checkpointer=None):
return create_agent(model=model or ReplayToolModel(), tools=[search_notes], context_schema=AgentContext,
system_prompt="先查笔记,再根据工具返回的原文回答,并保留引用编号。",
checkpointer=checkpointer)tools 登记允许执行的工具,context_schema 说明上下文的类型;invoke 的 context=AgentContext(...) 再传入这次执行的实际对象。模型接收 query 的参数说明,工具执行时则能同时读取 Runtime 中的当前用户。工具内部使用当前身份筛选数据,查询文字里写了别人的名字也不会改变这个身份。
"""让 LangChain 实际执行一次工具调用,并显示每条消息的角色。"""
from langchain_core.messages import HumanMessage, ToolMessage
from studyagent.retrieval import NoteIndex
from studyagent.tool_agent import AgentContext, build_agent, search_notes
# 公开给模型的参数只有 query;当前用户从 Runtime 注入。
print(search_notes.tool_call_schema.model_json_schema()["properties"])
result = build_agent().invoke(
{"messages": [HumanMessage(content="NumPy 切片怎样避免改动原数组?")]},
context=AgentContext(owner="alice", index=NoteIndex()),
)
for message in result["messages"]:
print(message.type, getattr(message, "tool_calls", []), message.content)
assert any(isinstance(message, ToolMessage) for message in result["messages"])执行后依次能看到 human、带 tool_calls 的 ai、tool、最后回答的 ai。第一条 ai 的工具调用带 id=lookup-1,随后 ToolMessage 的 tool_call_id 与它对应;这样多次调用时也知道一份结果回答了哪条请求。
| 时刻 | 消息列表 | 此时已完成的动作 |
|---|---|---|
| 第一次模型调用前 | [human] |
只有问题,尚未查询 |
| 模型请求工具后 | [human, ai(tool_calls)] |
确定工具名、query 和调用编号 |
| 工具执行后 | [human, ai(tool_calls), tool] |
已取回本轮材料并关联调用编号 |
| 第二次模型调用后 | [human, ai(tool_calls), tool, ai(answer)] |
根据材料形成最终回答 |
每次模型输出后,Agent 检查有没有新的 tool_calls。有就继续执行并追加结果,没有就结束这一轮。默认回放固定走“检索一次、回答一次”;更复杂的问题可以产生后续调用,继续使用同样的消息交接方式。
把真实模型传给 build_agent(model=...) 后,由它根据消息与工具说明选择调用。程序仍需检查参数、执行函数、保存结果和决定是否继续;模型输出工具名称本身不会执行 Python、SQL 或 HTTP。工具与 Runtime 参数