上周在做一个内部运维Agent的时候,遇到一个典型问题:用户问"服务器怎么又挂了",Agent需要查监控、查日志、查变更记录,最后给出结论。如果让LLM自己编造答案,那就是胡说八道;如果每次都人工介入,那Agent就失去了意义。最终靠Function Calling + 多步推理解决了这个问题。

这篇文章把Function Calling的实现细节和多步推理的工程实践整理出来,包括如何定义工具、如何让LLM自主决定调用顺序、以及踩过的几个坑。

Function Calling是什么

简单说,就是让LLM能"调用函数"。你告诉它有哪些工具可用、每个工具接受什么参数,LLM自己决定什么时候调用哪个工具、传什么参数。你拿到调用请求后去执行,把结果喂回LLM,它再继续推理。

整个流程可以用这个图来概括:

flowchart TD A[用户提问] --> B[LLM分析意图] B --> C{需要工具?} C -->|否| D[直接回答] C -->|是| E[生成工具调用] E --> F[执行工具] F --> G[返回结果给LLM] G --> B

定义工具

以查服务器状态为例,定义一个获取监控指标的工具:

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_server_metrics",
            "description": "获取服务器的实时监控指标,包括CPU、内存、磁盘使用率",
            "parameters": {
                "type": "object",
                "properties": {
                    "server_id": {
                        "type": "string",
                        "description": "服务器ID,如srv-001"
                    },
                    "metric": {
                        "type": "string",
                        "enum": ["cpu", "memory", "disk", "all"],
                        "description": "要查询的指标类型"
                    },
                    "duration": {
                        "type": "string",
                        "description": "查询时间范围,如1h、24h",
                        "default": "1h"
                    }
                },
                "required": ["server_id", "metric"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "get_recent_logs",
            "description": "获取服务器最近的错误日志",
            "parameters": {
                "type": "object",
                "properties": {
                    "server_id": {
                        "type": "string",
                        "description": "服务器ID"
                    },
                    "level": {
                        "type": "string",
                        "enum": ["error", "warn", "info"],
                        "description": "日志级别"
                    },
                    "limit": {
                        "type": "integer",
                        "description": "返回条数,默认20"
                    }
                },
                "required": ["server_id"]
            }
        }
    }
]

工具定义的关键是description要写得清楚。LLM是靠自然语言理解来决定调哪个工具的,description写得模糊它就会选错。

调用LLM并处理工具请求

import json
from openai import OpenAI

client = OpenAI()

def run_agent(user_message, max_rounds=5):
    messages = [
        {"role": "system", "content": "你是一个运维助手,可以查询服务器监控和日志。"},
        {"role": "user", "content": user_message}
    ]

    for round in range(max_rounds):
        response = client.chat.completions.create(
            model="gpt-4o",
            messages=messages,
            tools=tools,
            tool_choice="auto"
        )

        msg = response.choices[0].message

        # 没有工具调用,直接返回回答
        if not msg.tool_calls:
            return msg.content

        # 处理每个工具调用
        messages.append(msg)
        for tc in msg.tool_calls:
            result = execute_tool(tc.function.name, json.loads(tc.function.arguments))
            messages.append({
                "role": "tool",
                "tool_call_id": tc.id,
                "content": json.dumps(result, ensure_ascii=False)
            })

    return "推理轮数超过限制,请简化问题。"

这里max_rounds=5是防止LLM陷入无限循环。实际跑下来,简单问题1轮就够,复杂排查通常3-4轮。

工具执行层

def execute_tool(name, args):
    if name == "get_server_metrics":
        # 实际会调监控系统API
        return query_prometheus(args["server_id"], args.get("metric", "all"), args.get("duration", "1h"))
    elif name == "get_recent_logs":
        return query_elasticsearch(args["server_id"], args.get("level", "error"), args.get("limit", 20))
    else:
        return {"error": f"未知工具: {name}"}

这个层要把LLM的参数翻译成实际的API调用。注意要做参数校验,LLM有时候会传一些奇怪的值。

多步推理的关键

多步推理是Function Calling最强大的地方。LLM不是只调一次工具就完事,它会根据第一次的结果决定要不要再调别的工具。

举个实际场景:

flowchart TD A[用户: 服务器怎么又挂了] --> B[LLM: 先查CPU] B --> C[CPU: 98%] C --> D[LLM: 再查日志] D --> E[日志: OOM Killer触发] E --> F[LLM: 查最近变更] F --> G[变更: 昨晚上线新功能] G --> H[LLM: 结论 - 新功能内存泄漏]

这个过程中LLM自己决定了调用顺序:先看指标、再看日志、再查变更。你不需要写死这个流程,LLM会根据上下文自主判断。

踩过的坑

工具描述不准确导致调错

一开始我把get_server_metrics的description写成了"获取服务器信息",结果用户问"服务器在哪"的时候LLM也去调这个工具。改成"获取服务器实时监控指标"后就对了。

教训:description要精确描述工具的功能边界,不要写得太笼统。

参数类型不匹配

LLM有时候会把整数传成字符串,或者把枚举值拼写错误。建议在工具执行层加一层参数校验:

def validate_params(schema, params):
    """简易参数校验"""
    for field, rules in schema.get("properties", {}).items():
        if field in params:
            if rules.get("type") == "integer":
                params[field] = int(params[field])
            if "enum" in rules and params[field] not in rules["enum"]:
                return None, f"{field} must be one of {rules['enum']}"
    return params, None

循环调用停不下来

有些场景LLM会反复调同一个工具。比如查日志没找到想要的信息,它会换个参数再查一次,还是没找到,又换个参数。加了max_rounds限制后解决了,但更好的做法是让LLM在工具返回结果不理想时直接告诉用户"没找到相关信息"。

# 在system prompt里加一句约束
"如果工具返回的信息不足以回答问题,直接告诉用户现有信息不足,不要反复调用相同工具。"

流式输出支持

Function Calling也支持流式输出,但实现起来更复杂:

def run_agent_stream(user_message, max_rounds=5):
    messages = [
        {"role": "system", "content": "你是一个运维助手。"},
        {"role": "user", "content": user_message}
    ]

    for round in range(max_rounds):
        stream = client.chat.completions.create(
            model="gpt-4o",
            messages=messages,
            tools=tools,
            stream=True
        )

        tool_calls_acc = []
        final_content = ""

        for chunk in stream:
            delta = chunk.choices[0].delta
            if delta.content:
                final_content += delta.content
                yield delta.content
            if delta.tool_calls:
                # 累积工具调用
                for tc in delta.tool_calls:
                    while len(tool_calls_acc) <= tc.index:
                        tool_calls_acc.append({"id": "", "function": {"name": "", "arguments": ""}})
                    if tc.id:
                        tool_calls_acc[tc.index]["id"] = tc.id
                    if tc.function.name:
                        tool_calls_acc[tc.index]["function"]["name"] = tc.function.name
                    if tc.function.arguments:
                        tool_calls_acc[tc.index]["function"]["arguments"] += tc.function.arguments

        if not tool_calls_acc:
            return

        # 执行工具并继续
        msg = {"role": "assistant", "tool_calls": [
            {"id": tc["id"], "type": "function", "function": tc["function"]}
            for tc in tool_calls_acc
        ]}
        messages.append(msg)
        for tc in tool_calls_acc:
            result = execute_tool(tc["function"]["name"], json.loads(tc["function"]["arguments"]))
            messages.append({
                "role": "tool",
                "tool_call_id": tc["id"],
                "content": json.dumps(result, ensure_ascii=False)
            })

流式版本代码量大了不少,但用户体验好很多——用户能实时看到LLM的思考过程和工具调用结果。

性能优化

并行工具调用

如果LLM一次返回多个工具调用请求,可以并行执行:

import asyncio

async def execute_tools_parallel(tool_calls):
    tasks = []
    for tc in tool_calls:
        name = tc.function.name
        args = json.loads(tc.function.arguments)
        tasks.append(asyncio.to_thread(execute_tool, name, args))
    return await asyncio.gather(*tasks)

实测并行执行可以节省30-50%的响应时间,尤其是需要同时查多个数据源的时候。

工具结果压缩

有时候工具返回的数据量很大,比如日志查询可能返回几百行。直接全部喂给LLM会浪费token,建议做个摘要:

def compress_tool_result(result, max_chars=2000):
    text = json.dumps(result, ensure_ascii=False)
    if len(text) <= max_chars:
        return text
    # 截断并加提示
    return text[:max_chars] + f"\n... (结果已截断,原始长度{len(text)}字符)"

写在最后

Function Calling是构建AI Agent的基础能力。做好了,LLM就是一个能自主决策、按需调用工具的智能体;做不好,就是一个会调错函数的麻烦制造者。

关键要点:工具描述要精确、参数校验要严格、循环调用要限制、结果要压缩。把这几个点做好,你的Agent就稳定了。