agent初探 的文章封面
返回文章列表
Neuroblue writing

agent初探

从基础开始,手搓自己的agent

Agent 初探:从一次 API 调用到最小工具调用

你是一个编程新手,看到 Agent 这么火,也想自己尝试一番。

但如果一上来就学 LangChain、LlamaIndex 或各种 Agent 框架,很容易只会“调包”,却不知道 Agent 到底多了什么。

所以这一篇先不碰框架,只从最小的大模型 API 调用开始,一步一步往上加:

单次 API 调用
-> 循环对话
-> 简单上下文记忆
-> 工具注册
-> 工具调用

先说结论:

Agent 不是“会聊天的大模型”,而是“大模型 + 上下文/记忆 + 工具 + 控制循环”的组合。

这一篇先做到最小工具调用。它还不是成熟 Agent,但已经能看到 Agent 的雏形。

最小 API 调用

第一步,先让 Python 能和大模型说上一句话。

import os

from dotenv import load_dotenv
from openai import OpenAI
from openai.types.chat import ChatCompletionMessageParam


load_dotenv()


def require_env(name: str) -> str:
    value = os.getenv(name)
    if not value:
        raise ValueError(f"请先配置 {name}。")
    return value


api_key = require_env("LLM_API_KEY")
model_id = require_env("LLM_MODEL_ID")
base_url = require_env("LLM_BASE_URL")

llm = OpenAI(api_key=api_key, base_url=base_url)

user_input = input(">>: ").strip()
messages: list[ChatCompletionMessageParam] = [
    {"role": "user", "content": user_input}
]

res = llm.chat.completions.create(
    model=model_id,
    messages=messages,
)

print("AI:", res.choices[0].message.content)

这里做了几件事:

  • load_dotenv():从 .env 读取环境变量。
  • require_env():确保关键配置存在,并把返回值收敛成 str
  • OpenAI(...):创建一个 OpenAI SDK 客户端。
  • messages:描述这次对话输入。
  • chat.completions.create(...):发起一次聊天模型请求。

.env 里通常会放:

LLM_API_KEY=你的 API Key
LLM_MODEL_ID=你的模型 ID
LLM_BASE_URL=你的服务地址

如果你调用的是兼容 OpenAI API 的第三方模型服务,例如 DeepSeek、Qwen 或本地网关,通常也是通过 base_url 切换服务地址。

ChatCompletionMessageParam 是什么

from openai.types.chat import ChatCompletionMessageParam

ChatCompletionMessageParam 是 OpenAI SDK 提供的聊天消息类型。

它表示 messages 里的每一条消息应该长什么样,例如:

{"role": "system", "content": "你是一个助手"}
{"role": "user", "content": "你好"}
{"role": "assistant", "content": "你好,有什么可以帮你?"}

它主要用于类型提示,不会改变程序运行逻辑。

如果你在 VS Code 里用了 Pylance,类型声明会让补全和报错更舒服。比如:

messages: list[ChatCompletionMessageParam] = [
    {"role": "user", "content": "你好"}
]

这句话的意思是:

messages 是一个列表,列表里的元素是 Chat Completions API 能识别的消息对象。

client.chat.completions.create 代表什么

你不了解 OpenAI SDK,于是查了文档,发现请求长这样:

response = client.chat.completions.create(
    model=model,
    messages=messages,
)

它可以拆成四层:

client        OpenAI 客户端
.chat         使用聊天模型相关能力
.completions  创建“聊天补全”任务
.create()     发起一次新的请求

更直白一点:

chat.completions.create() 的意思是:给模型一组对话消息,让模型补全下一条 assistant 回复。

比如你给模型:

[
    {"role": "user", "content": "你好"}
]

模型会返回类似:

{"role": "assistant", "content": "你好!有什么可以帮你?"}

这类接口对应的 API 资源是 Chat Completions。它的核心输入是 messages,核心输出是模型生成的下一条消息。

为什么要取 response.choices[0].message.content

第一次运行时,你可能会直接:

print("AI:", res)

然后看到一大坨对象:

ChatCompletion(
    id='...',
    choices=[
        Choice(
            finish_reason='stop',
            index=0,
            message=ChatCompletionMessage(
                content='你好!很高兴见到你!...',
                role='assistant',
                tool_calls=None,
                ...
            )
        )
    ],
    usage=...
)

这是因为 SDK 返回的不是一个字符串,而是一个完整响应对象。

它大概长这样:

response
├── id
├── object
├── created
├── model
├── choices
│   └── [0]
│       ├── index
│       ├── finish_reason
│       └── message
│           ├── role
│           ├── content
│           ├── tool_calls
│           └── ...
└── usage
    ├── prompt_tokens
    ├── completion_tokens
    └── total_tokens

其中:

  • id:这次请求的唯一 ID。
  • model:实际使用的模型。
  • choices:模型返回的候选答案列表。
  • message:其中一条 assistant 消息。
  • content:这条消息的正文。
  • usage:token 用量,后续估算成本和优化上下文时很有用。

所以我们真正想展示给用户看的正文在:

res.choices[0].message.content

逐层看:

res
-> choices[0]
-> message
-> content

为什么是 choices[0]

因为 API 设计上支持一次返回多个候选答案。默认通常只有一个,所以取第一个。

循环对话

单次调用只能问一句,答一句。

如果你想一直和 AI 聊,就可以加一个循环:

while True:
    user_input = input(">>: ").strip()

    if user_input in {"exit", "quit"}:
        break

    messages: list[ChatCompletionMessageParam] = [
        {"role": "user", "content": user_input}
    ]

    res = llm.chat.completions.create(
        model=model_id,
        messages=messages,
    )

    print("AI:", res.choices[0].message.content)

这时你可以连续输入问题。

但注意:这还不是多轮对话。

因为每次循环里,messages 都被重新创建了:

messages = [{"role": "user", "content": user_input}]

也就是说,模型每次只能看到当前这一句话。

它不知道上一轮你说过什么。

这就是一个非常重要的概念:

大模型 API 默认是无状态的。它不会自动记住上一轮请求。

所谓多轮对话,不是模型自己记住了,而是我们把历史消息重新发给它。

简单记忆:维护历史消息

如果你想让 AI 记住当前对话,就需要维护一个 history

最小版本是:

history: list[ChatCompletionMessageParam] = []

while True:
    user_input = input(">>: ").strip()

    if user_input in {"exit", "quit"}:
        break

    history.append({"role": "user", "content": user_input})

    res = llm.chat.completions.create(
        model=model_id,
        messages=history,
    )

    assistant_content = res.choices[0].message.content or ""
    history.append({"role": "assistant", "content": assistant_content})

    print("AI:", assistant_content)

这里的关键不是“列表”本身,而是列表里的结构必须是合法的消息。

每一轮都要按顺序追加:

user message
assistant message
user message
assistant message
...

这样下一次请求时,模型就能看到完整对话历史。

不过这只是“上下文记忆”,不是长期记忆。

它有几个限制:

  • 程序一关,history 就没了。
  • 对话越来越长,token 成本会越来越高。
  • 超过模型上下文窗口后,旧消息需要裁剪或总结。

所以我们现在得到的是:

循环对话 + 短期上下文

这还不是完整 Agent,但已经比单轮问答更进一步了。

工具调用:让模型不只会说话

聊天还不够,你希望它能操作一下本地电脑。

比如:让它列出某个文件夹里的文件。

这里就进入了工具调用。

工具调用的核心分工是:

模型负责决定要调用哪个工具,以及传什么参数。
程序负责真正执行工具,并把结果返回给模型。

模型不会真的访问你的电脑。

它只会输出类似:

我要调用 list_folder_files,参数是 {"folder_path": "..."}

真正读取文件夹的是 Python 程序。

注册一个工具

先写一个普通 Python 函数:

from pathlib import Path


def list_folder_files(folder_path: str) -> str:
    folder = Path(folder_path)

    if not folder.exists() or not folder.is_dir():
        return f"无效文件夹: {folder_path}"

    return "\n".join(sorted(item.name for item in folder.iterdir())) or "(空文件夹)"

然后用 JSON Schema 描述这个工具:

tools = [
    {
        "type": "function",
        "function": {
            "name": "list_folder_files",
            "description": "列出指定本地文件夹中的文件名",
            "parameters": {
                "type": "object",
                "properties": {
                    "folder_path": {
                        "type": "string",
                        "description": "要列出文件的本地文件夹路径",
                    }
                },
                "required": ["folder_path"],
                "additionalProperties": False,
            },
        },
    }
]

这里有三个关键点:

  • name:工具名,模型会通过这个名字请求调用工具。
  • description:工具说明,模型靠它判断什么时候该用这个工具。
  • parameters:参数结构,告诉模型应该生成什么格式的参数。

把工具传给模型:

res = llm.chat.completions.create(
    model=model_id,
    messages=history,
    tools=tools,
)

tools 存在时,模型可以选择:

  • 直接回复文本。
  • 请求调用一个或多个工具。

如果它决定调用工具,返回的 assistant message 里会出现:

assistant_message.tool_calls

工具调用的完整流程

工具调用不是一次请求就结束,而是两段式:

第一次请求:
用户问题 -> 模型决定是否调用工具

程序执行:
解析 tool_calls -> 调用本地函数 -> 得到工具结果

第二次请求:
把工具结果发回模型 -> 模型基于结果生成最终回答

可以理解为:

user -> model -> tool_call -> python function -> tool result -> model -> final answer

其中很容易漏掉一个字段:

tool_call_id

工具结果消息必须带上 tool_call_id,这样模型才知道:

这个工具结果是在回应哪一次工具调用。

最小工具调用代码

代码长起来像这样:

import json
import os
from pathlib import Path

from dotenv import load_dotenv
from openai import OpenAI


load_dotenv()


def require_env(name: str) -> str:
    value = os.getenv(name)
    if not value:
        raise ValueError(f"请先配置 {name}。")
    return value


api_key = require_env("LLM_API_KEY")
model_id = require_env("LLM_MODEL_ID")
base_url = require_env("LLM_BASE_URL")

llm = OpenAI(api_key=api_key, base_url=base_url)


def list_folder_files(folder_path: str) -> str:
    folder = Path(folder_path)

    if not folder.exists() or not folder.is_dir():
        return f"无效文件夹: {folder_path}"

    return "\n".join(sorted(item.name for item in folder.iterdir())) or "(空文件夹)"


tools = [
    {
        "type": "function",
        "function": {
            "name": "list_folder_files",
            "description": "列出指定本地文件夹中的文件名",
            "parameters": {
                "type": "object",
                "properties": {
                    "folder_path": {
                        "type": "string",
                        "description": "要列出文件的本地文件夹路径",
                    }
                },
                "required": ["folder_path"],
                "additionalProperties": False,
            },
        },
    }
]

history: list[dict] = [
    {
        "role": "system",
        "content": "你是一个谨慎的本地文件助手。只有在需要查看文件夹内容时才调用工具。",
    }
]

while True:
    user_input = input(">>: ").strip()

    if user_input in {"exit", "quit"}:
        break

    history.append({"role": "user", "content": user_input})

    res = llm.chat.completions.create(
        model=model_id,
        messages=history,
        tools=tools,
    )

    assistant_message = res.choices[0].message
    history.append(assistant_message.model_dump(exclude_none=True))

    tool_calls = assistant_message.tool_calls or []

    if tool_calls:
        for tool_call in tool_calls:
            function_name = tool_call.function.name
            arguments = json.loads(tool_call.function.arguments or "{}")

            if function_name == "list_folder_files":
                tool_result = list_folder_files(arguments["folder_path"])
            else:
                tool_result = f"未知工具: {function_name}"

            history.append(
                {
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "content": tool_result,
                }
            )

        res = llm.chat.completions.create(
            model=model_id,
            messages=history,
            tools=tools,
        )

        assistant_message = res.choices[0].message
        history.append(assistant_message.model_dump(exclude_none=True))

    print("AI:", assistant_message.content)

这段代码已经具备了一个最小 Agent 的影子:

  • 有对话历史。
  • 有工具描述。
  • 模型能决定是否调用工具。
  • 程序能执行工具。
  • 工具结果会回到模型上下文里。

但它还不是成熟 Agent。

现在它还缺什么

这个程序已经不只是普通聊天机器人了,但距离真正好用的 Agent 还有几个关键差距。

1. 缺少明确目标

现在它只是被动回答用户问题。

成熟 Agent 往往会围绕一个目标持续推进任务,比如:

帮我整理这个项目目录,找出入口文件,并总结运行方式。

它需要拆解任务、执行步骤、观察结果,再决定下一步。

2. 缺少稳定的循环控制

现在最多只处理一轮工具调用。

真正的 Agent Loop 通常是:

模型思考
-> 调用工具
-> 得到观察结果
-> 再思考
-> 再调用工具
-> 直到完成或达到最大步数

所以后面需要加:

  • max_steps
  • trace 日志
  • 停止条件
  • 错误处理

3. 缺少长期记忆

现在的 history 只是当前程序运行期间的上下文。

如果程序退出,历史就没了。

长期记忆通常需要保存到:

  • Markdown
  • JSON
  • SQLite
  • 向量数据库

但一开始不用急。先理解 history,再理解长期记忆。

4. 缺少安全边界

本地文件工具很敏感。

学习阶段可以直接传路径,但真实项目至少要考虑:

  • 是否限制可访问目录。
  • 是否允许读取隐藏文件。
  • 是否允许删除、写入、执行命令。
  • 工具异常是否会泄露敏感信息。

工具调用的原则是:

能让模型决定调用什么,但不要让模型绕过程序的权限边界。

小结

这一篇我们从最小 API 调用开始,逐步加到了工具调用:

单次调用:messages -> response
循环对话:while True
上下文记忆:history
工具注册:tools
工具执行:tool_calls -> Python 函数
最终回答:tool result -> model

你现在已经摸到了 Agent 的门槛。

下一步可以继续做两件事:

  1. 把工具调用放进一个多步循环,形成真正的 Agent Loop。
  2. 给每一步保存 trace,观察模型到底在什么时候选择了工具、什么时候直接回答。

最后,用一句话收束:

Agent 的核心不是“模型更聪明”,而是程序把模型、上下文、工具和循环组织到了一起。

参考