Bootstrap

本地大模型编程实战(11)与外部工具交互(2)


在使用 LLM(大语言模型) 时,经常需要调用一些自定义的工具方法完成特定的任务,比如:执行一些特殊算法、查询天气预报、旅游线路等。
很多大模型都具备使用这些工具方法的能力,Langchain 也为这些调用提供了便利。

之前的文章介绍了 llama3.1 与工具方法交互的实际例子,不过可惜 langchaindeepseek 支持还不够,导致:

  • llm.bind_tools 根据用户问题生成的工具方法签名与 llama3.1 不同,在后续在调用工具方法时报错
  • deepseek 返回的结果中包含了思考过程内容,显然 Langchain 还不能正确解析出最终结果,这会导致 langchain 的很多方法不能正常运行

这次我们将尝试通过以下两种方法解决 Langchain 使用 deepseek 时产生的上述问题:

  • 使用提示词让大模型推理调用工具的方法名称和参数
  • 使用自定义的 JsonOutputParser 处理 deepseek 返回的信息

这里使用 llama3.1deepseek 等不同模型做对比,并不是为了说明孰优孰劣,而是仅仅为了技术演示需要。

准备

在正式开始撸代码之前,需要准备一下编程环境。

  1. 计算机
    本文涉及的所有代码可以在没有显存的环境中执行。 我使用的机器配置为:

    • CPU: Intel i5-8400 2.80GHz
    • 内存: 16GB
  2. Visual Studio Code 和 venv
    这是很受欢迎的开发工具,相关文章的代码可以在 Visual Studio Code 中开发和调试。 我们用 pythonvenv 创建虚拟环境, 详见:
    在Visual Studio Code中配置venv

  3. Ollama
    Ollama 平台上部署本地大模型非常方便,基于此平台,我们可以让 langchain 使用 llama3.1qwen2.5 等各种本地大模型。详见:
    在langchian中使用本地部署的llama3.1大模型

定义工具方法

下面定义了两个简单的工具方法:计算加法和乘法:

def create_tools():
    """创建tools"""
    @tool
    def add(x: int, y: int) -> int:
        """计算a和b的和。"""
        print (f"add is called...{x}+{y}")
        return x + y

    @tool
    def multiply(x: int, y: int) -> int:
        """计算a和b的乘积。"""
        print (f"multiply is called...{x}*{y}")
        return x * y
    
    tools = [add, multiply]

    for t in tools:
        print("--")
        print(t.name)
        print(t.description)
        print(t.args)

    return tools

tools = create_tools()

上述代码执行后,会打印出 tools 的 名称 、描述 和 形参 :

--
add
计算a和b的和。
{'x': {'title': 'X', 'type': 'integer'}, 'y': {'title': 'Y', 'type': 'integer'}}
--
multiply
计算a和b的乘积。
{'x': {'title': 'X', 'type': 'integer'}, 'y': {'title': 'Y', 'type': 'integer'}}

创建提示词

rendered_tools = render_text_description(tools)
print(rendered_tools)

system_prompt = f"""\
您是一名助理,有权使用以下工具集。
以下是每个工具的名称和说明:

{rendered_tools}

根据用户输入,返回要使用的工具的名称和输入。
以 JSON blob 形式返回您的响应,其中包含“name”和“arguments”键。

“arguments”应该是一个字典,其中的键对应于参数名称,值对应于请求的值。
"""

prompt = ChatPromptTemplate.from_messages(
    [("system", system_prompt), ("user", "{input}")]
)

render_text_description 方法生成了 tools 的描述:

add(x: int, y: int) -> int - 计算a和b的和。
multiply(x: int, y: int) -> int - 计算a和b的乘积。

这些描述在后面添加到提示词中,LLM 应该能通过这个完整的提示词生成工具方法的 参数(即:实参)了,我们后面试试看。

生成工具方法实参

定义测试方法:

def too_call(model_name,query):
    llm = ChatOllama(model=model_name,temperature=0.1,verbose=True)

    chain = prompt | llm
    message = chain.invoke({"input": query})
    print(f'response: \n{message.content}') 

lamma3.1deepseek 返回的结果为:

  • lamma3.1
{
    "name": "multiply",
    "arguments": {
        "x": 3,
        "y": 12
    }
}
  • deepseek-r1
<think>
好,我现在需要解决用户的问题:“3乘以12等于多少?” 用户希望我作为助理,使用提供的工具来计算。首先,我要理解用户的需求是什么。

...

最后,我需要将这些信息整合成一个JSON对象,并确保语法正确,避免任何错误导致返回失败。这样,当用户调用这个工具时,就能得到正确的结果了。
</think>

```json
{
  "name": "multiply",
  "arguments": {
    "x": 3,
    "y": 12
  }
}
```

llama3.1deepseek-r1 都正确生成了工具方法的实参,只是 deepseek 包含了 <think>...</think> 块,后面需要将其中的 json 部分提取出来。

json 格式返回实参

自定义 JsonOutputParser

一般来说,结构化的数据在 链 中才好处理, JsonOutputParser 用于将结果转换为 json 格式,我们先自定义一个类,它继承自 JsonOutputParser 并能处理 deepseek 的返回文本。

class ThinkJsonOutputParser(JsonOutputParser):
    def parse_result(self, result: list[Generation], *, partial: bool = False) -> Any:
        """将 LLM 调用的结果解析为 JSON 对象。支持deepseek。

        Args:
            result: LLM 调用的结果。
            partial: 是否解析 partial JSON 对象。
                If True, 输出将是一个 JSON 对象,其中包含迄今为止已返回的所有键。
                If False, 输出将是完整的 JSON 对象。
                默认值为 False.

        Returns:
            解析后的 JSON 对象。

        Raises:
            OutputParserException: 如果输出不是有效的 JSON。
        """

        text = result[0].text
        text = text.strip()

        # 判断是否为 deepseek生成的内容,如果是的话,提取其中的json字符串
        if '<think>' in text and '</think>' in text:  
            match = re.search(r'\{.*\}', text.strip(), re.DOTALL)
            if match:
                text = match.group(0)
        result[0].text = text

        return super().parse_result(result, partial=partial)

上述方法使用正则表达式将 deepseek 返回的 json 内容提取出来,这样处理后,deepseek 就可以加入 langchain 中了。

返回 json

query = "3 * 12等于多少?"

def too_call_json(model_name,query):
    """以json格式输出"""
    llm = ChatOllama(model=model_name,temperature=0.1,verbose=True)

    chain = prompt | llm | ThinkJsonOutputParser()
    message =chain.invoke({"input": query})
    print(f'JsonOutputParser: \n{message}')

我们用两个大模型分别测试,这次返回的结果一样:

{'name': 'multiply', 'arguments': {'x': 3, 'y': 12}}

调用工具方法

定义通用方法

我们先定义一个通用的调用工具方法的方法:

class ToolCallRequest(TypedDict):
    """invoke_tool 函数使用的参数格式。"""

    name: str
    arguments: Dict[str, Any]


def invoke_tool(
    tool_call_request: ToolCallRequest, config: Optional[RunnableConfig] = None
):
    """执行工具调用的函数。

    Args:
        tool_call_request: 包含键名和参数的字典。
            `name` 必须与已存在的工具名称匹配。
            `arguments` 是工具函数的参数。
        config: 这是 LangChain 使用的配置信息,其中包含回调、元数据等内容。

    Returns:
        requested tool 的输出
    """
    tool_name_to_tool = {tool.name: tool for tool in tools}
    name = tool_call_request["name"]
    requested_tool = tool_name_to_tool[name]
    return requested_tool.invoke(tool_call_request["arguments"], config=config)

用 链 返回结果

现在我们可以用 langchain 整合以上成果:

def invoke_chain(model_name,query):
    llm = ChatOllama(model=model_name,temperature=0.1,verbose=True)

    chain = prompt | llm | ThinkJsonOutputParser() | invoke_tool
    result =chain.invoke({"input": query})
    print(f'invoke_chain:\n{result}')

调用此方法,我们会发现 llama3.1deepseek-r1 都返回了简单的结果:

36

返回结果中包含工具输入

返回工具输出和工具输入都很有帮助。我们可以通过 RunnablePassthrough.assign 输出,这将获取 RunnablePassthrough 组件的输入并为其添加一个键,同时仍传递当前输入中的所有内容。

def invoke_chain_with_input(model_name,query):
    llm = ChatOllama(model=model_name,temperature=0.1,verbose=True)

    from langchain_core.runnables import RunnablePassthrough

    chain = (
        prompt | llm | ThinkJsonOutputParser() | RunnablePassthrough.assign(output=invoke_tool)
    )
    result = chain.invoke({"input": query})
    print(f'invoke_chain with input:\n{result}')

调用此方法,我们会发现 llama3.1deepseek-r1 返回了同样的结果:

{'name': 'multiply', 'arguments': {'x': 3, 'y': 12}, 'output': 36}

完美!

总结

在这篇文章里,我们通过直接使用提示词和自定义 json解析类 的方法,让 deepseek-r1 也完美的嵌入到 langchain 中,从而完成了对 工具方法 的调用。

代码

本文涉及的所有代码以及相关资源都已经共享,参见:

参考:

🪐祝好运🪐

;