Agent工具调用范式:ReAct 和Function Calling

0 阅读6分钟

Agent工具调用范式

最近看到qwen3.5发布了,但是对于Function Calling 没有找到适配好的模型,所以用ReAct模式来试用一下。之前基于qwen3做了Function Calling模式的agent。刚好有机会把两种主流的工具调用范式都落地了一遍,索性整理记录一下我踩过的坑,以及两种范式的真实差异,给大家做个参考。


项目前置:先搭好我们的知识库

做客服 Agent,首先得有业务知识库,用来做售后政策的检索,我写了一个简单的ingest.py脚本,把我们的售后政策转成了 FAISS 向量库:

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 的状态机来管理多轮交互,工具调用都是标准化的结构化数据,不用自己处理文本解析。

核心实现

# 对比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 约束模型,让它用自然语言模拟「思考 - 行动 - 观察」的循环,所有的交互都是纯文本,不依赖模型的原生能力。

核心实现

# 最关键的就是这个硬编码的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 块剥离了:

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 版本的坑

  1. Prompt 的鲁棒性真的是生命线:一开始直接用FC模式,模型有时候会输出中文的「思考:」而不是Thought:,导致解析器直接报错,最后只能用强约束的 Prompt,再加解析错误兜底。
  2. Action Input 的格式兼容:LLM 有时候会把输入写成 JSON 对象,而不是纯字符串,导致工具解析失败,最后我在工具里加了一个 JSON 解包的逻辑,自动处理这种情况。
  3. max_iterations 要调大:因为解析失败会消耗迭代次数,一开始设的 5,很容易就耗尽了,最后调到 10,给容错留了空间。

Function Calling 版本的坑

  1. 模型必须支持 Function Calling:一开始我用Qwen3.5 模型,不支持 FC,输出的完全和预想的不一样,模型会输出..+json,导致LangGraph完全没法运作。中间看了很多都没有找到比较好的,最后换成了 Qwen3 的才行。