构建 AI Agent 的起点是一个简单的想法:让模型能够执行动作、观察结果,并形成循环。模型针对目标进行推理,决定下一步做什么,调用某个工具,获取结果,然后继续执行,直到任务完成。这个“读取-评估-行动”循环,无论你构建的是单步工具调用器,还是能编写代码、运行并自我纠错的多步系统,都是其核心所在。
本教程将带你逐一了解构成 Agent 的四个组件:规划、工具使用、代码执行和评估。学完之后,你将建立起一个可运行的心智模型和一套极简实现方案。
什么构成了 AI Agent
AI Agent 与基础 LLM 调用的关键区别在于:它能够执行动作并观察其结果。
普通的聊天补全只生成文本。而 Agent 则利用这些文本来调用函数、查询 API、运行命令或读取文件——并将结果作为下一步的输入反馈回来。正是这个反馈循环,使得 Agent 能够处理那些无法通过单次响应完成的任务。
一个 Agent 具备三种核心能力:
- 工具调用 — 通过结构化的参数按名称调用定义的函数
- 上下文累积 — 随着对话增长,将工具执行结果持续带入
- 目标导向的循环 — 持续运行直到满足停止条件,而非仅仅生成一次响应
如果某个系统同时具备这三者,那你就是在构建 Agent。如果缺少上下文累积或循环机制,那它只是一个工具增强型聊天机器人。
同样的架构可以处理广泛的任务:能够搜索并综合信息的研究型 Agent、能够编写并运行代码的编程型 Agent、能够查询数据库并生成报告的数据型 Agent。每种场景下的实现模式几乎完全相同。
第一步:规划 — 推理层
规划层是模型决定下一步行动的地方。你不需要一个独立的规划模型——生成文本的同一个模型,可以在每一步针对目标进行推理,并产生结构化的行动(即工具调用)。
系统提示词完成了大部分工作。一个优秀的规划提示词应告知模型:
- 它正在努力实现什么目标
- 哪些工具可用,以及每个工具的功能
- 调用工具时应使用什么格式
- 何时停止(完成条件)
使用兼容 OpenAI 的 Novita LLM API 时,示例如下:
import os
from openai import OpenAI
client = OpenAI(
base_url="https://api.novita.ai/v3/openai",
api_key=os.environ["NOVITA_API_KEY"],
)
tools = [
{
"type": "function",
"function": {
"name": "run_code",
"description": "执行 Python 代码并返回 stdout/stderr。",
"parameters": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "要执行的 Python 代码"
}
},
"required": ["code"]
}
}
}
]
messages = [
{
"role": "system",
"content": "你是一个编程助手。使用 run_code 工具执行 Python 代码来解决问题。当你得到最终答案时,请停止。"
},
{
"role": "user",
"content": "找出 50 以下的所有质数。"
}
]
response = client.chat.completions.create(
model="meta-llama/llama-3.1-8b-instruct",
messages=messages,
tools=tools,
tool_choice="auto",
)
模型返回的是一个工具调用——一个包含函数名和参数的 JSON 对象——而不是纯文本响应。你的代码需要提取这个对象,执行相应的函数,然后将结果附加到 messages 中并进入下一轮循环。
第二步:工具使用 — 将模型连接到世界
工具是 Agent 实际执行的动作。没有工具,模型只能生成文本;有了工具,它可以读取文件、调用 API、运行代码、搜索数据库或触发你定义的任何函数。
定义工具
每个工具都是一个描述函数的 JSON 模式。模型通过读取这个模式来正确地格式化调用请求。几条实用规则:
- 描述要保持简短精确。 模型在每一步都会读取这些描述。冗长的描述只会浪费 token。
- 参数名称要与函数实际接受的参数匹配。 模式与实现之间的不匹配会导致静默失败。
- 返回结构化的输出。 如果你的工具返回原始 HTML 或未格式化的文本,模型会浪费 token 去解析它。应返回带有清晰标签的干净字符串或 JSON。
工具调用循环
模型发出工具调用后,你的代码需要:
- 从响应中解析出工具名称和参数
- 查找对应的 Python 函数
- 使用解析出的参数调用该函数
- 将结果以
role: "tool"的形式附加到messages中 - 发起另一次补全请求
循环持续进行,直到模型返回一条不包含工具调用的普通消息(即完成条件)。
import json
def run_agent_loop(client, model, messages, tools, tool_fns, max_steps=10):
for _ in range(max_steps):
response = client.chat.completions.create(
model=model,
messages=messages,
tools=tools,
tool_choice="auto",
)
choice = response.choices[0]
# 完成 — 没有工具调用
if choice.finish_reason == "stop":
return choice.message.content
# 执行工具调用
tool_call = choice.message.tool_calls[0]
fn_name = tool_call.function.name
fn_args = json.loads(tool_call.function.arguments)
result = tool_fns[fn_name](**fn_args)
# 同时附加模型的工具调用及结果
messages.append(choice.message)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": str(result),
})
return "已达到最大步数,未得到最终答案。"
这就是完整的 Agent 循环。max_steps 守卫防止了失控循环——如果模型陷入反复调用同一个工具的僵局,循环会终止而不是无限运行。其他所有功能——多工具编排、记忆、流式输出——都建立在这个模式之上。
常见工具模式
文件访问: 在 Agent 的工作目录内读写文件。将文件内容以字符串形式返回。
网络搜索: 以 {title, url, snippet} 对象列表的形式返回搜索结果。摘要要简短。
代码执行: 在隔离环境中执行代码并返回 stdout/stderr(下一节会详细讨论)。
结构化数据查询: 查询数据库或 API,并以 JSON 形式返回记录。模型负责进行总结。
每个工具应只做一件事,并且在相同输入下返回确定性的输出。试图在单个工具中塞入太多逻辑的 Agent,会变得更难调试和评估。
第三步:代码执行 — 安全地运行代码
如果你的 Agent 会生成并运行代码,那么你需要一个隔离的环境。直接在机器上运行模型生成的代码存在安全风险:模型可能会产生破坏性的 shell 命令、无限循环,或者读取其不应访问的敏感文件。
标准的做法是使用 沙盒——一个短生命周期的、隔离的 Linux 环境,Agent 可以在其中自由执行代码,而不会影响宿主机系统。Novita 的 Agent Sandbox 正是为此场景设计的:一个基于云的执行环境,在 200ms 内启动,运行 Agent 生成的任何代码,并在会话结束时销毁该环境。
以下是在规划部分中,run_code 工具使用沙盒的实现方式:
from novita_sandbox.code_interpreter import Sandbox
sandbox = Sandbox.create()
def run_code(code: str) -> str:
result = sandbox.run_code(code)
output = ""
if result.logs.stdout:
output += "\n".join(result.logs.stdout)
if result.logs.stderr:
output += "\nSTDERR: " + "\n".join(result.logs.stderr)
return output or "(无输出)"
当 Agent 调用 run_code 时,此函数在隔离环境中执行代码,并将 stdout/stderr 作为字符串返回。Agent 看到结果后,决定下一步做什么——无论是修复 bug、继续使用输出,还是宣告任务完成。
这种架构清晰地将推理层与执行层分离开来。LLM 负责规划和调用工具;沙盒负责运行代码,而不暴露容器之外的任何内容。
沙盒处理的内容
- Shell 命令(
bash、git、python、node) - 沙盒内的文件系统操作
- 容器内部的网络请求
- 通过异步结果检索进行的长时间运行的后台任务
- 用于多会话工作流的暂停与恢复
沙盒不允许的操作包括:访问宿主机系统、其他沙盒,或你未明确传入的凭据。正是这种隔离性,使得让模型编写并运行任意代码变得安全。
第四步:评估 — 判断 Agent 是否正确
评估是 Agent 开发中最容易被跳过的一步。能够产生输出的 Agent,并不等同于能够产生正确输出的 Agent。
三个评估层级
1. 确定性检查
对于有已知正确答案的任务——如解析日期、计算数值、提取字段——可以针对 Agent 的输出编写单元测试。这类测试运行成本低,且能快速捕获回归问题。
2. 基于执行的检查
对于编程 Agent,最可靠的信号是:代码是否真正运行并产生了正确的结果?在沙盒中执行 Agent 的输出,并将 stdout 与期望值进行比较。这比比较代码文本更可靠,因为它测试的是行为,而非语法。
def evaluate_code_task(agent_code: str, expected_stdout: str) -> bool:
result = run_code(agent_code)
return result.strip() == expected_stdout.strip()
3. LLM 作为评判者
对于开放式任务——如撰写摘要、回答研究问题——使用第二个 LLM 调用,根据评分标准对输出进行评分。评分标准要具体:“答案是否包含一个可运行的代码示例?” 比 “答案好不好?” 更有效。模糊的评分标准会产生不一致的分数。
追踪失败模式
Agent 的失败通常表现为以下几种重复模式:
| 失败类型 | 原因 | 修复方法 |
|---|---|---|
| 工具调用循环 | 模型反复调用同一个工具 | 设置 max_steps;记录中间步骤 |
| 参数不匹配 | 模式与实现不一致 | 在调用前验证工具参数 |
| 上下文溢出 | 会话超出模型的上下文限制 | 对较早的工具结果进行总结或截断 |
| 捏造的工具名称 | 模型虚构了一个不存在的工具 | 使用严格的工具列表,配合 tool_choice: "auto" |
评估不是开发末尾的一个独立阶段——它本身就是开发的一部分。每次扩展 Agent 的能力时,至少要为新行为编写一个能够捕获回归的测试。
为 Agent 推理选择模型
大多数教程在涉及 Agent 推理时,默认使用闭源模型。这在原型开发阶段效果不错,但在生产环境中——一个 Agent 可能每个任务需要 20 次 LLM 调用——模型成本和速率限制会成为真正的约束。
通过 Novita LLM API 托管的开源模型,在 Agent 实际需要的任务上——函数调用、结构化输出、多步推理——正逐渐匹配闭源模型的性能。像 Llama 3.3 70B 和 DeepSeek-V3 这样的模型,在编码和指令遵循方面达到了 GPT-4 级别的水平,而运行成本却只有几分之一。对于高吞吐量的 Agent 工作负载,这种差异会迅速累积。
Novita API 完全兼容 OpenAI,因此从闭源模型切换到开源模型只需一行代码:
# 之前
model = "gpt-4o"
# 之后 — 相同的 API 契约,更低的成本
model = "meta-llama/llama-3.3-70b-instruct"
对于对延迟敏感的 Agent 循环,像 meta-llama/llama-3.1-8b-instruct 这样的小模型响应更快,每一步的成本也更低,这在每个用户任务涉及多次顺序调用时尤为重要。一个实用的模式是:在路由和分类步骤中使用更快、更小的模型,而在需要更多能力的复杂推理步骤中使用更大的模型。
Novita 的模型库还为特定的 Agent 子任务提供了强大的选项——用于处理基于图像的工具结果的视觉模型、用于检索的嵌入模型,以及用于编程 Agent 的代码专用模型——所有这些都可以通过同一个 API 端点访问。
整合在一起 — 一个最小可用 Agent
下面是一个完整的 Agent:它接收一个数学问题,生成 Python 代码来求解,在沙盒中运行代码,并返回验证后的结果:
import os
import json
from openai import OpenAI
from novita_sandbox.code_interpreter import Sandbox
client = OpenAI(
base_url="https://api.novita.ai/v3/openai",
api_key=os.environ["NOVITA_API_KEY"],
)
sandbox = Sandbox.create()
def run_code(code: str) -> str:
result = sandbox.run_code(code)
stdout = "\n".join(result.logs.stdout)
stderr = "\n".join(result.logs.stderr)
return stdout or stderr or "(无输出)"
tool_fns = {"run_code": run_code}
tools = [
{
"type": "function",
"function": {
"name": "run_code",
"description": "执行 Python 代码。返回 stdout 或 stderr。",
"parameters": {
"type": "object",
"properties": {
"code": {"type": "string"}
},
"required": ["code"]
}
}
}
]
messages = [
{
"role": "system",
"content": "通过编写并运行 Python 代码来解决问题。使用 run_code 来执行代码。当你得到验证后的答案时,请停止。"
},
{
"role": "user",
"content": "求 1,000,000 以下所有偶数斐波那契数的和。"
}
]
for _ in range(10):
response = client.chat.completions.create(
model="meta-llama/llama-3.1-8b-instruct",
messages=messages,
tools=tools,
tool_choice="auto",
)
choice = response.choices[0]
if choice.finish_reason == "stop":
print(choice.message.content)
break
tool_call = choice.message.tool_calls[0]
fn_args = json.loads(tool_call.function.arguments)
result = tool_fns[tool_call.function.name](**fn_args)
messages.append(choice.message)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result,
})
sandbox.kill()
这段代码用大约 50 行涵盖了完整的 Agent 循环:规划、工具调用、执行、上下文累积、最大步数限制,以及干净的沙盒清理。Agent 编写代码、运行代码、查看输出,然后要么继续执行,要么给出最终答案。
若要扩展它:在 tool_fns 和 tools 中添加更多工具,为更复杂的任务换用更大的模型,或者将沙盒的 run_code 替换为任何其他函数——网络搜索、数据库查询、API 调用。循环结构保持不变。
常见问题
AI Agent 和聊天机器人有什么区别?
聊天机器人生成文本回复。而 Agent 则执行动作——调用工具、运行代码、查询 API——并利用结果来继续朝着目标推理。是否存在工具调用和反馈循环,是区分 Agent 的关键。
构建 AI Agent 是否需要 LangChain 这样的框架?
不需要。核心的 Agent 循环足够简单,可以直接对着任何兼容 OpenAI 的 API 实现。框架在复杂的编排场景中(多 Agent 系统、持久化记忆、流式输出)有它的价值,但它们增加了抽象层,可能使调试变得更困难。先从原始 API 开始,如果你确实需要框架提供的功能,再考虑引入。
如何防止 Agent 运行危险代码?
使用沙盒化的执行环境。Agent 的代码在一个隔离的容器中运行,该容器无法访问你的宿主机系统或其他进程。沙盒在会话结束后会被销毁,因此 Agent 所做的任何更改都仅限于该容器。
开源模型能可靠地处理函数调用吗?
可以——但需注意模型规模。Llama 3.3 70B、DeepSeek-V3 和 Qwen 2.5 72B 都能很好地处理结构化的工具调用。较小的模型(7B–8B)适用于简单、定义清晰的工具模式,但在面对模糊指令或复杂的多步推理链时可能会遇到困难。对于生产级 Agent,在确定使用某个模型之前,请在实际的任务分布上进行测试。
Agent 每个任务应该进行多少步?
设置一个硬性上限——对于大多数任务,10 到 20 步是典型值。记录中间步骤,这样你可以清楚地看到 Agent 做了什么,并调试失败原因。持续需要更多步骤的任务,通常更适合拆分为子 Agent 或子任务,而不是简单地去扩展循环上限。
