HuanCode Docs

Harness实战:从API调用到Function Calling

Agent 工程的第一步不是写循环,而是搞清楚怎么调大模型。一篇讲透 OpenAI、Claude、DeepSeek 三大 API 的异同,从基础调用到多轮对话到工具调用。

写在前面

上一篇《Harness Engineering:开发者的下一个必修课》讲了一个公式:

Agent = Model + Harness

公式很优雅,但动手写 Harness 的第一步是什么?

不是设计 Agent Loop,不是写工具注册表,而是一个更基础的问题——你得先能调通大模型的 API

API 调用是 Harness 的地基。调不通 API,后面的多轮对话、工具调用、Agent Loop 全是空中楼阁。

这篇文章把地基打扎实:从三大模型 API 的基础调用讲起,到多轮对话的本质,再到 Function Calling 的完整流程,最后看一眼内置 Web Search 工具。每一步都有可运行的代码。


三大模型 API:相似但不相同

目前主流的 LLM 服务商有三家:OpenAI、Anthropic(Claude)、DeepSeek。它们的 API 调用流程高度相似——获取 Key、安装 SDK、调用接口——但在细节上各有差异。

OpenAI

from openai import OpenAI
client = OpenAI()

response = client.responses.create(
    model="gpt-5.4",
    input="Write a one-sentence bedtime story about a unicorn."
)

print(response.output_text)

OpenAI 最新推荐使用 Responses APIresponses.create),取代了之前的 Chat Completions API。输入用 input 参数(字符串或消息数组),输出直接取 response.output_text

Claude (Anthropic)

import anthropic

client = anthropic.Anthropic()

message = client.messages.create(
    model="claude-opus-4-6",
    max_tokens=1000,
    messages=[
        {
            "role": "user",
            "content": "What should I search for to find the latest developments in renewable energy?",
        }
    ],
)
print(message.content)

DeepSeek

import os
from openai import OpenAI

client = OpenAI(
    api_key=os.environ.get('DEEPSEEK_API_KEY'),
    base_url="https://api.deepseek.com")

response = client.chat.completions.create(
    model="deepseek-chat",
    messages=[
        {"role": "system", "content": "You are a helpful assistant"},
        {"role": "user", "content": "Hello"},
    ],
    stream=False
)

print(response.choices[0].message.content)

DeepSeek 没有自己的 SDK,直接用 OpenAI SDK + 自定义 base_url 即可。注意 DeepSeek 使用的是 OpenAI 旧版 Chat Completions API(chat.completions.create),而非最新的 Responses API。这也是很多国产模型的做法——兼容 OpenAI 格式,降低迁移成本。

一句话总结三家差异

维度OpenAI(Responses API)ClaudeDeepSeek
SDKopenaianthropic复用 openai
调用方法responses.createmessages.createchat.completions.create
系统指令instructions 参数顶层 system 参数messages 中的 system 角色
输入参数input(字符串或消息数组)messages 列表messages 列表
响应取值response.output_textmessage.content[0].textresponse.choices[0].message.content
max_tokens可选(有默认值)必填可选

LiteLLM:一套代码调所有模型

每家 API 格式不同,难道要为每个模型写一套代码?

不用。LiteLLM 是一个统一调用层,底层自动转换不同厂商的 API 格式,对外统一暴露 OpenAI 兼容接口。

from openai import OpenAI

# 指向 LiteLLM 代理,一个入口调所有模型
client = OpenAI(
    api_key=os.environ["API_KEY"],
    base_url=os.environ["BASE_URL"],
)

# 只需切换 model 参数
for model in ["gpt-5", "claude-sonnet-4.6", "deepseek-v3.2"]:
    response = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": "用一句话介绍你自己。"}],
    )
    print(f"[{model}] {response.choices[0].message.content}")

三个模型、三个厂商,代码完全一样,只改 model 名。这就是 LiteLLM 的价值——在 Harness 工程中,它让你的工具层和编排层不需要关心底层用的是哪家模型。


核心参数对比

不管是哪家 API,核心就三个参数:modelmessagestools

model:选哪个模型

直接传模型名称字符串。OpenAI 是 gpt-5.4,Claude 是 claude-opus-4-6,DeepSeek 是 deepseek-chat。通过 LiteLLM 统一调用时,模型名是自己定义的。

messages:对话历史

messages 是一个列表,每条消息有 rolecontent 两个字段。

参数OpenAI(Responses API)Claude
输入格式input 可以是字符串或消息数组messages 列表,每条含 role + content
消息角色developeruserassistantuserassistant(system 用顶层参数)
content 格式字符串或内容块数组字符串或内容块数组

content 可以是简单字符串,也可以是内容块数组——支持文本、图片、文件等多种类型。这种设计让模型能处理多模态输入,agent 可以根据需要传入不同类型的内容。

tools:赋予模型行动能力

tools 是工具列表,赋予模型调用外部能力(搜索、文件操作、API 调用等)。这是模型从"回答者"变成"执行者"的关键。

工具能力说明OpenAIClaude
Function Calling调用开发者自定义函数
Web Search搜索互联网获取实时数据
Computer Use控制计算机界面
Shell / Bash执行 shell 命令
MCP 接入连接第三方服务
File Search搜索上传文件内容
Image Generation生成或编辑图片
Text Editor内置文本编辑工具

正是因为有了 tools,我们才能构建 agent。


多轮对话:Agent 记忆的基础

单轮调用只是 hello world。真正的 agent 需要多轮对话——记住用户说过什么,逐步推进任务。

多轮对话的本质很简单:维护一个 messages 列表,每轮追加 user 和 assistant 消息

messages = []
system_prompt = "你是一个旅行规划助手,帮用户规划旅行行程。回答简洁。"

user_inputs = [
    "我想去日本旅行,有什么推荐?",
    "我比较喜欢第二个,大概需要几天?",
    "帮我列一个 5 天的行程大纲。",
]

for user_input in user_inputs:
    messages.append({"role": "user", "content": user_input})

    response = client.messages.create(
        model="claude-sonnet-4.6",
        max_tokens=1000,
        system=system_prompt,
        messages=messages,
    )

    assistant_reply = response.content[0].text
    messages.append({"role": "assistant", "content": assistant_reply})

关键观察:每一轮请求都携带完整的对话历史

第一轮,messages 里只有一条用户消息。第二轮,messages 里有第一轮的 user + assistant + 第二轮的 user,共三条消息。第三轮,五条消息。

模型本身是无状态的——它不记得上一次请求。所谓"记忆",完全靠客户端维护 messages 列表来实现。

这就是为什么 token 消耗会随对话轮次增长——因为每次都把完整历史发过去了。这也是后续 context management(上下文管理)要解决的核心问题。

理解多轮对话的实现方式,是理解 Agent Loop、Context Management、Memory 等 agent 核心概念的基础。


Function Calling:模型学会"动手"

多轮对话让模型有了记忆,但它依然只能生成文本。Function Calling 让模型能调用工具执行任务——这是 agent 的雏形。

流程拆解

Function Calling 分三步:

用户提问 → 模型返回 tool_use(我要调这个函数) 
         → 开发者执行函数 
         → 把结果通过 tool_result 返回给模型 
         → 模型生成最终回答

用一个真实的天气查询场景来演示完整流程。

第一步:定义工具

告诉模型有哪些函数可用,传入函数名、描述和参数 schema:

tools = [
    {
        "name": "get_weather",
        "description": "获取指定城市的当前天气信息",
        "input_schema": {
            "type": "object",
            "properties": {
                "city": {
                    "type": "string",
                    "description": "城市名称,如 北京、上海",
                },
            },
            "required": ["city"],
        },
    }
]

第二步:实现工具

这里调用高德天气API实现:

def city_to_adcode(city: str) -> str | None:
    """通过高德地理编码 API 将城市名动态转换为 adcode"""
    resp = requests.get(
        "https://restapi.amap.com/v3/geocode/geo",
        params={"key": AMAP_KEY, "address": city},
        timeout=5,
    )
    data = resp.json()
    if data.get("status") == "1" and data.get("geocodes"):
        return data["geocodes"][0]["adcode"]
    return None

def get_weather(city: str) -> dict:
    """调用高德天气 API 获取实况天气"""
    adcode = city_to_adcode(city)
    if not adcode:
        return {"error": f"无法识别城市「{city}」"}

    resp = requests.get(
        "https://restapi.amap.com/v3/weather/weatherInfo",
        params={"key": AMAP_KEY, "city": adcode, "extensions": "base"},
        timeout=5,
    )
    data = resp.json()
    live = data["lives"][0]
    return {
        "city": live["city"],
        "weather": live["weather"],
        "temperature": f"{live['temperature']}°C",
        "humidity": f"{live['humidity']}%",
        "wind": f"{live['winddirection']}{live['windpower']}级",
    }

第三步:执行工具并返回结果

# 通过工具注册表动态分派,而非写死调用某个函数
tool_registry = {
    "get_weather": get_weather,
}

tool_results = []
for block in tool_use_blocks:
    func = tool_registry.get(block.name)
    result = func(**block.input)

    tool_results.append({
        "type": "tool_result",
        "tool_use_id": block.id,
        "content": json.dumps(result, ensure_ascii=False),
    })

# 工具结果通过 user 角色的 tool_result 块返回
messages.append({"role": "user", "content": tool_results})

两轮交互的完整流程

当用户问"北京和上海今天天气怎么样?"时,实际发生了两轮 API 调用:

第一轮请求:用户消息 + tools 定义 → 模型分析后返回两条 tool_use 指令

{
  "role": "assistant",
  "content": "我来同时查询北京和上海的天气信息!",
  "tool_calls": [
    {"function": {"name": "get_weather", "arguments": "{\"city\": \"北京\"}"}},
    {"function": {"name": "get_weather", "arguments": "{\"city\": \"上海\"}"}}
  ]
}

注意 finish_reasontool_calls 而非 stop——模型在说"我还没完,先帮我执行这些工具"。

开发者执行工具:调用真实的高德天气 API,拿到北京和上海的实时天气数据。

第二轮请求:完整对话历史(user → assistant 含 tool_use → user 含 tool_result)→ 模型生成最终回答

🏙️ 北京 — 雾,10°C,湿度 88%,东北风 ≤3级
🏙️ 上海 — 阴,26°C,湿度 56%,西风 ≤3级

这就是 Function Calling 的完整闭环:模型决定调什么、传什么参数,开发者负责执行,结果返回给模型做最终整合

为什么用 tool_registry

你可能注意到代码里用了 tool_registry 字典做动态分派,而不是直接 get_weather(**block.input)

原因很简单:真实的 agent 会有多个工具。模型返回的 block.name 可能是 get_weather,也可能是 search_flightsbook_hotel。用注册表模式,新增工具只需加一行映射,不需要改分派逻辑。


内置 Web Search:服务端工具

Function Calling 是客户端工具——模型告诉你要调什么,你来执行。

还有一种是服务端工具(Server Tool)——模型自己调、自己拿结果、直接回答,开发者只需要声明一下。

Anthropic 的内置 Web Search 就是典型的服务端工具:

tools = [
    {
        "type": "web_search_20260209",
        "name": "web_search",
        "max_uses": 3,
        "user_location": {
            "type": "approximate",
            "country": "CN",
            "city": "Beijing",
            "timezone": "Asia/Shanghai",
        },
    }
]

response = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=2000,
    tools=tools,
    messages=[{"role": "user", "content": "Claude Code 最新版本有什么新功能?"}],
)

一次 API 调用就搞定,不需要实现任何搜索逻辑。响应中会包含 web_search_tool_result 类型的 content block,里面是搜索结果。

可选的控制参数:

参数说明
max_uses单次请求最多搜索几次
allowed_domains限制只搜索指定域名
blocked_domains屏蔽指定域名
user_location用户位置,获得本地化搜索结果

两种工具模式的对比

维度Function Calling(客户端工具)Server Tool(服务端工具)
执行者开发者模型服务商
API 轮次至少两轮(调用 + 结果)一轮
灵活性高(自定义任何逻辑)低(只能用服务商提供的)
适用场景自定义业务逻辑通用能力(搜索、代码执行)

从 API 到 Agent 的距离

这篇文章覆盖了 Harness 工程的地基层:

API 基础调用 → 多轮对话 → Function Calling → 内置工具

如果你跟着代码跑了一遍,你已经掌握了:

  1. 三大模型 API 的调用方式和差异——知道该用哪个 SDK、参数怎么传
  2. 多轮对话的本质——就是维护 messages 列表,模型本身无状态
  3. Function Calling 的完整流程——模型决策 → 开发者执行 → 结果返回
  4. 服务端工具的用法——声明即用,无需实现

但从这里到一个真正的 agent,还差什么?

  • Agent Loop:把 Function Calling 的"两轮"变成"循环"——模型可能需要多次调用工具才能完成任务
  • Context Management:messages 列表会越来越长,token 会爆,需要压缩和裁剪
  • Tool Registry:工具越来越多,需要动态加载和管理
  • Error Handling:工具执行失败怎么办?模型幻觉怎么办?

这些就是后续文章要讲的内容。

地基打好了,该盖楼了。


本文完整代码

  • 基础调用:llm-openai.py / llm-claude.py / llm-litellm.py
  • 多轮对话:multi-turn.py
  • Function Calling:tool-function-calling.py
  • Web Search:tool-web-search.py

On this page