从0开始搭建一个 Agent
上一篇 从0开始搭建本地 AI 服务 搭出来的服务只会"说话"——你问一句,它答一句,仅此而已。这一篇给它装上"手":让模型能执行命令、查询数据、调用外部 API,并能根据执行结果继续决策,直到完成一个多步骤任务。这个东西就是 Agent。
学完之后你会拥有:一个跑在本地模型上、能自己查系统状态、执行命令的 Agent,并且知道它每一行逻辑在做什么。
Agent 与聊天机器人的区别
聊天机器人 |
Agent |
|
|---|---|---|
输入 |
一段话 |
一个任务 |
输出 |
一段话 |
一系列动作 + 最终结果 |
能力 |
只会"说" |
会"做":调用工具、观察结果、继续决策 |
Agent 的定义可以压缩成一句话:
Agent = LLM(大脑) + 工具(手) + 循环(神经系统)
+------------------------------------------------------+
| Agent 循环 |
| |
| +----------+ tool_call(我要执行这个) +----------+ |
| | | ------------------------> | 工具 | |
| | LLM | | (shell/ | |
| | (决策) | <------------------------ | API/...) | |
| | | tool result(执行结果) +----------+ |
| +----------+ |
| | |
| | 最终答案(不再调用工具) |
+--------v----------------------------------------------+
三个角色各司其职:
LLM 只负责决策:读对话历史,决定"下一步做什么"——是提议调用某个工具,还是直接给出最终答案;
工具负责执行:真正去操作系统、网络、数据库的那部分代码;
循环负责衔接:把工具的执行结果作为新消息塞回对话,让 LLM 基于结果继续决策。
原理一:模型是怎么"调用工具"的
一个反直觉但必须建立的认识:模型不会执行任何东西。它从头到尾只做一件事——输出 token。所谓"调用工具",是模型输出一段格式特殊的文本(tool call),由你的代码去执行,然后把执行结果作为一条新消息塞回对话。模型只是"提出"要调用什么,真正动手的是外面那个循环。
协议:OpenAI 兼容的 Tool Calling
业界已经把这个过程标准化了(OpenAI 的 function calling 协议,本地引擎如 llama.cpp、Ollama、vLLM 都实现了它),整个交互分三步:
sequenceDiagram
participant C as 你的代码
participant M as 本地推理服务
C->>M: 第1步: messages + tools 定义
M->>C: assistant 消息, 带 tool_calls 而不是普通文本
C->>C: 第2步: 执行工具, 得到结果
C->>M: 第3步: 历史 + tool 结果消息, 再发一次
M->>C: 最终答案, 或再次返回 tool_calls 回到第2步
图示:一次对话可能来回多轮。每一轮模型要么给出最终答案,要么给出要调用的工具——后一种情况循环继续。
工具怎么定义
你发给服务端的请求里,除了 messages,还要带上 tools 数组,用 JSON Schema 描述每个工具:
{
"type": "function",
"function": {
"name": "run_shell",
"description": "在服务器上执行一条 shell 命令, 返回 stdout 和 stderr",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "要执行的 shell 命令"
}
},
"required": ["command"]
}
}
}
这段 JSON 就是工具给模型看的"说明书":模型读到它,就知道"存在一个叫 run_shell 的工具,参数是 command,我可以提议调用它"。
模型的 tool_calls 长什么样
模型看完说明书,如果决定调用工具,返回的消息里就带 tool_calls:
{
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_9f3a2b",
"type": "function",
"function": {
"name": "run_shell",
"arguments": "{\"command\": \"free -h\"}"
}
}]
}
两个容易踩坑的细节:
arguments是 JSON 字符串,不是对象——代码里要先json.loads()再取字段;每个 tool_call 有一个唯一的
id,回传结果时要原样带回去(见下)。
执行结果怎么回传
你执行完工具,把结果包装成 role: "tool" 的消息追加到对话里:
{
"role": "tool",
"tool_call_id": "call_9f3a2b",
"content": " total used free ...\nMem: 31Gi 3.1Gi 25Gi ..."
}
服务端收到后,模型就能"看到"执行结果,基于它继续生成——可能是再提议调用一个工具,也可能是给出最终答案。
原理二:为什么要循环(ReAct)
单个任务往往需要多步才能完成。比如"系统内存不够了,帮我找原因":
查看内存使用情况(调 run_shell 执行
free -h);发现某个进程占用异常,查它的详情(再调 run_shell 执行
ps aux | grep xxx);得出结论,给出最终答案。
一次调用只能提议一个动作,所以 Agent 的骨架必然是一个循环。这种"推理-行动-观察"的模式就是 2022 年提出的 ReAct(Reason + Act):
flowchart TB
A[用户任务] --> B[LLM 决策]
B --> C{下一步?}
C -- 调用工具 --> D[执行工具]
D --> E[结果追加进对话]
E --> B
C -- 给出最终答案 --> F[返回用户]
B -. 轮数超过上限 .-> G[强制终止]
图示:循环的出口有两个——模型给出最终答案(正常出口),或者轮数超过上限(兜底出口,防止死循环烧钱)。
动手:30 行代码写一个 Agent
依赖只有一个 openai 的 Python SDK——它把上面三步协议封装好了,而且 base_url 指向哪里,就和哪套 OpenAI 兼容接口对话(上一篇的 llama.cpp / Ollama 都可以):
pip install openai
完整代码:
import json
import subprocess
from openai import OpenAI
# 指向上一篇文章搭好的本地服务(llama.cpp / Ollama / vLLM 均可)
# 注意 model 名要和服务的标签一致:llama.cpp 随意, Ollama 要写 "qwen2.5:7b"
client = OpenAI(base_url="http://localhost:8000/v1", api_key="local")
TOOLS = [
{
"type": "function",
"function": {
"name": "run_shell",
"description": "在服务器上执行一条 shell 命令, 返回 stdout 和 stderr",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "要执行的 shell 命令"},
},
"required": ["command"],
},
},
},
]
def execute_tool(name, arguments):
"""工具的实际实现, 与 TOOLS 里的定义一一对应"""
if name == "run_shell":
result = subprocess.run(
arguments["command"],
shell=True,
capture_output=True,
text=True,
timeout=30, # 超时强制终止, 防止命令挂死
)
return result.stdout + result.stderr
raise ValueError(f"未知工具: {name}")
def run_agent(user_input, max_rounds=10):
# 对话历史就是 Agent 的全部记忆
messages = [{"role": "user", "content": user_input}]
for _ in range(max_rounds):
response = client.chat.completions.create(
model="qwen2.5-7b-instruct",
messages=messages,
tools=TOOLS,
)
message = response.choices[0].message
if not message.tool_calls:
# 没有 tool_calls, 说明这是最终答案
return message.content
# 把 assistant 消息(含 tool_calls)原样放回历史
messages.append(message.model_dump())
# 逐个执行, 结果以 role="tool" 回传
for call in message.tool_calls:
args = json.loads(call.function.arguments)
result = execute_tool(call.function.name, args)
messages.append(
{
"role": "tool",
"tool_call_id": call.id,
"content": result,
}
)
print(f"[tool] {call.function.name}({call.function.arguments})")
print(f"[result] {result[:200]}")
return "达到最大轮数, Agent 未给出最终答案"
if __name__ == "__main__":
answer = run_agent("查看系统内存使用情况, 如果剩余内存小于 1GB 就提醒我")
print("=" * 40)
print(answer)
运行:
python agent.py
一次典型的输出:
[tool] run_shell({"command": "free -h"})
[result] total used free shared buff/cache available
Mem: 31Gi 3.1Gi 25Gi 118Mi 2.7Gi 27Gi
Swap: 2.0Gi 0B 2.0Gi
========================================
系统当前剩余内存约 27GB, 远大于 1GB, 无需提醒。
逐段拆解
整个程序只有三个关键点:
messages就是 Agent 的全部记忆。每一轮都把所有历史完整发回服务端——模型没有别的记忆,你删掉历史,它就"忘"了;含 tool_calls 的 assistant 消息必须原样放回历史(
message.model_dump())。漏掉它,模型不知道"自己刚才提过什么";tool 消息必须带
tool_call_id,与服务端要求的调用一一对应。填错或填漏,模型会把结果张冠李戴。
加第二个工具:扩展就是这么简单
加一个计算器工具,验证"工具即函数"的扩展性:
TOOLS.append({
"type": "function",
"function": {
"name": "calculate",
"description": "计算数学表达式, 如 '2*3+4'",
"parameters": {
"type": "object",
"properties": {
"expression": {"type": "string", "description": "数学表达式"},
},
"required": ["expression"],
},
},
})
def execute_tool(name, arguments):
if name == "run_shell":
...
if name == "calculate":
# 教学示例用 eval, 真实环境换成更安全的表达式解析器
return str(eval(arguments["expression"]))
raise ValueError(f"未知工具: {name}")
工具的定义(JSON)和实现(Python 函数)一一对应,增加工具 = 各加一段,循环代码一行不用改。这也是为什么业界要把工具标准化成 MCP 协议(见下文)——让工具的增删与 Agent 代码解耦。
设计工具的要点
工具是 Agent 的"手",但模型只见过你的 JSON 描述,没见过你的代码。工具设计直接决定 Agent 用得好不好:
description 是说明书:写清楚"这个工具干什么、什么时候该用它"。描述含糊,模型要么不用,要么乱用;
参数少而明确:参数越多,模型填错概率越大。能合并的参数就合并;
返回值短小结构化:工具输出会被塞回对话上下文。甩给模型 1 万行日志,既挤爆 context,又稀释注意力。宁可工具内部先过滤、截断,只给结论;
结果要"对模型友好":带单位、带错误信息、明确成功/失败。模型看到
exit code 1才知道该换条路,而不是继续硬编。
危险工具:shell 权限 = 多大权限
run_shell 是教学里最直观、生产里最危险的工具。给它的权限有多大,取决于你怎么理解这句话:
警告
给 Agent 的 shell 权限,等于把 shell 权限交给"任何一个能给它发消息的人"。提示词注入(prompt injection)攻击的原理是:攻击者把指令伪装成网页内容、邮件正文、文件名,让模型误以为是任务的一部分。模型无法区分"用户说的"和"外部数据里夹带的",于是可能执行攻击者想要的命令。
生产环境收口权限的常规做法:
沙箱:工具跑在 docker/firejail 里,文件系统和网络隔离;
白名单:只允许执行预定义的命令清单,参数校验;
超时:每条命令强制 timeout(示例代码里的
timeout=30);人审:高危操作(删除、外发数据)执行前人工确认。
学习阶段可以放开玩,但养成"每个工具都问一句:最小权限是什么"的习惯,后面会省很多事。
Agent 常见失败与排查
症状 |
可能原因 |
处理 |
|---|---|---|
模型完全不返回 tool_calls |
模型未训练过 function calling,或 description 太含糊 |
换支持工具调用的 Instruct 模型;重写工具描述 |
反复调用同一个工具不收敛 |
工具结果每次一样,模型以为没执行成功 |
让工具返回更明确的"已成功/已失败"信号;限制轮数 |
|
小模型输出 JSON 不稳定 |
换 7B+ 模型;在 description 里给参数示例(模型会模仿) |
多轮后回答质量下降 |
上下文塞了太多工具输出 |
截断/摘要工具结果,或换用更大的 context |
编造工具结果 |
工具失败但模型幻觉"完成了" |
让工具在失败时返回显式错误,模型更可能如实转述 |
进阶:MCP 与多 Agent
MCP:统一工具协议
每换一个 Agent 框架(Claude Code、LangChain、自研循环),工具都要按它的格式重写一遍。MCP(Model Context Protocol)解决的就是这个问题:把工具定义成统一协议,工具方只写一次,任何支持 MCP 的 Agent 都能直接用。
+----------+ MCP(JSON-RPC over stdio/HTTP) +-----------+
| MCP Host | <-------------------------------> | MCP Server |
| (Agent) | list_tools / call_tool / ... | (工具提供方) |
+----------+ +-----------+
MCP 把工具、资源、提示词三类能力标准化,主流 Agent 框架都已支持。对学习而言,先把手写循环跑通,再接触 MCP 会更有体感——协议解决的是"规模化复用",而它背后的原理和上面的三步协议完全一致。
多 Agent
单 Agent 的上下文有上限,复杂任务可以拆给多个 Agent:一个编排者(orchestrator)拆解任务、分派,多个工作者(worker)各自带少量工具执行。多 Agent 本质上还是"LLM + 工具 + 循环"的组合,只是把循环又套了一层,本篇不展开。
下一步
Agent 跑通了,但它是"单用户、无鉴权、手动启动"的学习形态。下一篇 从学习到生产 回答:把这个服务正式部署出去,交给很多人用时,应该怎么做。