Agent工具调用范式
最近看到qwen3.5发布了,但是对于Function Calling 没有找到适配好的模型,所以用ReAct模式来试用一下。之前基于qwen3做了Function Calling模式的agent。刚好有机会把两种主流的工具调用范式都落地了一遍,索性整理记录一下我踩过的坑,以及两种范式的真实差异,给大家做个参考。
项目前置:先搭好我们的知识库
做客服 Agent,首先得有业务知识库,用来做售后政策的检索,我写了一个简单的ingest.py脚本,把我们的售后政策转成了 FAISS 向量库:
ini
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_ollama import OllamaEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document
# 版售后政策
raw_text = """
《智能助手售后政策 2026版》
1. 退款规则:未发货订单可随时申请全额退款。
2. 已发货规则:已发货订单需在签收后 7 天内,保持包装完好,联系客服申请退货。
3. 快递费用:非质量问题退货,由买家承担运费。
4. 特殊商品:定制化订单不支持 7 天无理由退换。
"""
documents = [Document(page_content=raw_text)]
# 用递归切分器做文本切片,保证语义不被拆断
text_splitter = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=20)
chunks = text_splitter.split_documents(documents)
# 用本地的nomic-embed-text做嵌入,生成向量库
embeddings = OllamaEmbeddings(model="nomic-embed-text")
vector_db = FAISS.from_documents(documents=chunks, embedding=embeddings)
vector_db.save_local("faiss_index")
跑通这个脚本,我们就有了本地的向量知识库,接下来就是做两个不同版本的 Agent 了。
第一个版本:原生结构化的 LangGraph Agent
Qwen3做的一个基于 LangGraph 的 Function Calling 版本。
这个版本的核心是依赖模型原生的 Function Calling 能力,用 LangGraph 的状态机来管理多轮交互,工具调用都是标准化的结构化数据,不用自己处理文本解析。
核心实现
ini
# 对比ReAct的长Prompt,这个System Prompt真的太简洁了,不用约束格式
system_prompt = """你是专业的企业客服。严禁捏造不存在的信息,必须基于工具返回的信息。
排版要求:
1. 严禁使用 `#` 等大号 Markdown 标题语法。
2. 多个订单请直接使用无序列表 `-` 排版,并在订单号上加粗。"""
# 一行代码创建Agent,LangGraph帮你做好了所有的事情
agent = create_react_agent(
model=llm, # 前提是模型要支持Function Calling
tools=[get_order_status, search_policy],
prompt=system_prompt,
checkpointer=memory, # 内置的状态持久化,自动帮你管理会话历史
)
上下文管理,ReAct 还要自己拼接chat_history字符串,LangGraph 只要传一个thread_id,它自动帮你把历史记录存起来,每次只需要传最新的用户消息就行,完全不用管历史的事情,开发效率高了太多。
第二个版本:纯文本驱动的 ReAct Agent
ReAct 的核心逻辑其实很简单:用 Prompt 约束模型,让它用自然语言模拟「思考 - 行动 - 观察」的循环,所有的交互都是纯文本,不依赖模型的原生能力。
核心实现
ini
# 最关键的就是这个硬编码的Prompt,强制约束模型的输出格式
REACT_PROMPT_TEMPLATE = """你是专业的企业客服。严禁捏造不存在的信息,必须基于工具返回的信息进行回答。
要使用工具,你必须且只能使用以下严格的格式:
Thought: 我需要使用工具吗? Yes
Action: 需要执行的工具名称,必须是 [{tool_names}] 中的一个
Action Input: 传给工具的输入参数
Observation: 工具返回的执行结果
当你已经收集到足够的信息来回答用户,或者你不需要使用任何工具时,你必须且只能使用以下格式输出最终答案:
Thought: 我需要使用工具吗? No
Final Answer: [在这里写下你给用户的最终回复,必须使用中文]
现在开始!
历史对话记录:
{chat_history}
用户的新输入: {input}
{agent_scratchpad}
"""
但是落地的时候,我踩了不少坑,比如 Qwen3.5 的<think>推理块,ReAct 的解析器根本看不懂,导致每次都解析失败,最后我加了一个小的处理函数,在 LLM 输出到解析器之前,把 think 块剥离了:
python
def _strip_think(msg: AIMessage) -> AIMessage:
cleaned = re.sub(r"<think>.*?</think>", "", msg.content, flags=re.DOTALL).strip()
# 防止返回空消息
if not cleaned:
cleaned = "..."
return AIMessage(
content=cleaned,
additional_kwargs=msg.additional_kwargs,
id=msg.id,
)
llm_for_agent = llm | RunnableLambda(_strip_think)
还有流式输出的问题,ReAct 的中间思考过程是给模型自己看的,不能给用户,所以我又加了一个滑动窗口缓冲,检测到Final Answer:标记之后,才把后面的内容推给前端,过滤掉中间的思考过程。
两种范式的核心差异
做完两个版本之后,我整理了一下它们的核心差异,大概是这样的:
| 对比维度 | ReAct 范式 | Graph/Function Calling 范式 |
|---|---|---|
| 模型依赖 | 全模型通用,无原生能力要求,小模型也能跑 | 模型专属,必须支持原生 Function Calling |
| 格式约束 | 纯文本 Prompt 驱动,自定义格式,全靠约束 | 原生结构化输出,标准字段,开箱即用 |
| 执行流程 | 文本解析循环,靠 AgentExecutor 解析文本 | 状态机循环,LangGraph 管理状态 |
| 上下文管理 | 手动拼接字符串,轻量但易错乱 | 按role的 messages 数组,自动持久化 |
| 工程复杂度 | 高,要自己处理各种兼容、过滤 | 低,框架已经做好了所有兼容 |
踩坑:那些我踩过的坑
ReAct 版本的坑
- Prompt 的鲁棒性真的是生命线 :一开始直接用FC模式,模型有时候会输出中文的「思考:」而不是
Thought:,导致解析器直接报错,最后只能用强约束的 Prompt,再加解析错误兜底。 - Action Input 的格式兼容:LLM 有时候会把输入写成 JSON 对象,而不是纯字符串,导致工具解析失败,最后我在工具里加了一个 JSON 解包的逻辑,自动处理这种情况。
- max_iterations 要调大:因为解析失败会消耗迭代次数,一开始设的 5,很容易就耗尽了,最后调到 10,给容错留了空间。
Function Calling 版本的坑
- 模型必须支持 Function Calling:一开始我用Qwen3.5 模型,不支持 FC,输出的完全和预想的不一样,模型会输出..+json,导致LangGraph完全没法运作。中间看了很多都没有找到比较好的,最后换成了 Qwen3 的才行。