最近聊 Agent 开发的越来越多,但很多人拿到一个想法后,真正动手的第一步就卡住了:项目该从哪建?模型怎么接?工具调用怎么搞?说实话,我见过太多人花了整整一个周末,结果全耗在环境配置和胶水代码上,核心的 Agent 逻辑一行没写。
这篇文章要解决的,就是用项目脚手架的思路,30 分钟搭好一个可运行的 Agent 服务骨架。不是那种只演示“调用一次大模型接口”的玩具,而是一个能跑通完整 Agent 循环的骨架:接收用户请求、让模型决策、调用工具、返回结果。它能直接作为你后续接业务、接知识库、接各种私有化能力的起点。适合刚接触 Agent 开发的人,也适合想规范化团队起手姿势的工程师。
1. Agent 服务骨架到底在搭什么
1.1 先分清 Agent 项目和普通接口项目的区别
很多人第一次做 Agent 项目时,容易把它当成“又一个 Web 后端”来写。路由、数据库、鉴权、部署一套组合拳下来,猛地发现 Agent 项目最核心的循环逻辑——模型决策加工具执行——反而没地方放了。
Agent 项目和非 Agent 项目的本质区别,在于它有一个“感知-决策-行动”的闭环。普通接口是请求进来、逻辑处理、结果返回,一次结束;Agent 则是模型先生成一个计划或一个工具调用请求,然后程序去执行这个请求,把执行结果再喂回给模型,模型再决定下一步做什么,直到它认为任务完成。这个过程在技术上叫做 Agent Loop(智能体循环),是整个服务骨架里最不应该被随意堆出来的部分。
我见过不少团队的做法是,把循环逻辑直接写在路由函数里,跟 HTTP 层耦合在一起。结果模型供应商一换、工具一多、上下文一长,代码立刻变得没法看。骨架阶段就要把这些边界划清楚。HTTP 接口是一层,Agent 引擎是一层,工具注册是一层,模型接入又是一层。每一层各干各的事,通过清晰的接口通信,后面加东西才不会牵一发动全身。
1.2 Agent 开发到底在做什么
从热搜词里也能看出来,“agent开发做什么的”“agent开发学习路线”这类问题一直有人问。我的理解是,Agent 开发的核心工作主要分四块:
第一块是模型接入与提示词工程,让大模型理解你的任务域;第二块是工具协议设计,让模型能调用你暴露的能力,比如查数据库、调 API、操作内部系统;第三块是记忆与上下文管理,决定 Agent 能记住多久之前的信息、哪些信息值得保留;第四块是服务化封装,把 Agent 能力变成可调用的接口,接进业务流程里。
一个合格的服务骨架,至少要把前四块的地基都打好。工具协议尤其关键,很多项目做到一半发现模型老是“瞎调用工具”,问题往往出在协议设计上:参数定义不清晰、工具描述太模糊、返回值格式不统一。这些都要在骨架阶段通过规范和样例提前规避。
1.3 为什么必须用脚手架思路而不是从零手写
脚手架(Scaffold)这个词来自传统 Web 开发,意思是你先搭好一个带目录结构、基础配置、通用逻辑的工程模板,然后在这个基础上填业务。Agent 项目特别适合这种方式,因为它有大量“每个项目都要写但每个项目都长一样”的公共代码:配置加载、模型客户端初始化、工具注册机制、请求日志、健康检查、统一响应格式。
从零手写的话,你大概率会在第五个项目时发现,自己把前四个项目的代码复制粘贴了四遍,还每次都要改半天。用脚手架思路,一开始就把这些公共能力沉淀成固定结构,后续每次开新项目,拉下来就是一套可运行的东西,省掉的不只是半小时,而是每个项目最初那几天的痛苦期。而且,脚手架本身就是团队约定的一种具象化。目录怎么分、配置怎么管、工具怎么加、错误怎么处理,全部沉淀在模板里,新成员进来照着做就行。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型与目录设计:一个能长期演进的骨架
2.1 技术栈的选择逻辑
这套骨架我推荐用 Python 3.11 以上版本,配合 FastAPI、Pydantic v2、LiteLLM 和 Docker。先解释一下为什么是这几个。
Python 是当前 Agent 生态最活跃的语言,没有之一。不管你想用 LangChain、LlamaIndex 还是自己手写引擎,Python 的库支持和社区案例都是最全的。FastAPI 则是因为它对异步支持好、自带 OpenAPI 文档、类型提示友好,做 Agent 服务非常适合——Agent 循环里有大量 I/O 等待,异步能力能显著提高并发表现。
LiteLLM 可能有些人还不熟,它的核心价值是统一模型接入层。你只需要会一个接口,就能对接 OpenAI、Anthropic、Google Gemini、Azure OpenAI、本地私有化模型等几十家供应商。对 Agent 项目来说,这一点太重要了。骨架阶段你根本不知道最后生产环境会用哪家模型,用了 LiteLLM,后续切换就是改一行配置的事,代码完全不用动。这也是我踩过坑之后才坚定的选择,早期项目直接写了 OpenAI SDK,后来公司要求换国产模型,所有调用代码全部重写了一遍,教训非常深刻。
Pydantic v2 则是用来做配置管理和数据校验的。Agent 项目的配置项非常多:模型名称、API Key、超时时间、最大循环次数、工具开关、日志级别,用 Pydantic 能把它们全部收敛到一个 Settings 对象里,启动时自动校验,环境变量一配就行。
2.2 目录结构的设计逻辑
项目脚手架最关键的就是目录结构。我下面给出一个经过多个项目验证的布局,你直接照着建就行:
text复制agent-skeleton/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI 入口
│ ├── core/
│ │ ├── __init__.py
│ │ ├── config.py # 配置管理
│ │ └── llm.py # 模型接入
│ ├── agent/
│ │ ├── __init__.py
│ │ ├── engine.py # Agent 循环
│ │ └── tools.py # 工具注册与示例工具
│ ├── api/
│ │ ├── __init__.py
│ │ └── routes.py # HTTP 路由
│ └── schemas/
│ ├── __init__.py
│ └── chat.py # 请求响应模型
├── tests/
├── .env.example
├── .gitignore
├── pyproject.toml
└── Dockerfile
这个结构的核心是分层清晰。core 层放跟业务无关的基础能力,比如配置和模型客户端;agent 层放 Agent 的核心逻辑和工具;api 层只做 HTTP 适配;schemas 层放接口数据模型。我跟不少人强调过一个经验:不要在 api 层直接写 Agent 循环逻辑,也不要在 agent 层直接引入 FastAPI 的 Request 对象。层与层之间靠 Python 函数调用和 Pydantic 模型通信,这样任何一层要替换实现,其他层都感知不到。
这个结构还有一个好处,就是你以后想加记忆模块、加向量库、加多 Agent 协作,都有明确的位置可以放,不会破坏现有结构。
3. 实操:30 分钟从初始化到跑通完整骨架
3.1 环境准备与项目初始化
第一步,确认你的 Python 版本。建议用 3.11 或更高版本,主要是为了更好的异步语法和类型提示支持。
bash复制python --version
mkdir agent-skeleton && cd agent-skeleton
python -m venv .venv
source .venv/bin/activate
然后创建 pyproject.toml 或者 requirements.txt。我推荐 pyproject.toml,依赖管理更规范:
toml复制[project]
name = "agent-skeleton"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.111.0",
"uvicorn[standard]>=0.30.0",
"pydantic>=2.7.0",
"pydantic-settings>=2.3.0",
"litellm>=1.40.0",
"python-dotenv>=1.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"httpx>=0.27.0",
]
安装依赖:
bash复制pip install -e ".[dev]"
这里要特别提醒一句:依赖版本要锁住。Agent 生态的库更新非常频繁,LiteLLM 甚至一周能发好几个版本,如果你不锁版本,今天能跑的项目一个月后可能就因为某个依赖的 breaking change 起不来了。我通常的做法是,pyproject.toml 里写一个兼容范围限制,同时在提交前用 pip freeze 导出一份 requirements.lock 文件。
3.2 配置管理:让骨架全局可配置
配置管理是整个骨架里最容易被低估的模块。Agent 项目的配置项比普通后端多得多,而且很多配置是运行时要根据实际环境动态覆盖的:开发用本地模型,测试用 mock,生产用正式模型。所以配置模块从一开始就要设计好。
在 app/core/config.py 里写入:
python复制from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
app_name: str = "agent-skeleton"
api_host: str = "0.0.0.0"
api_port: int = 8000
llm_provider: str = "openai"
llm_model: str = "gpt-4o-mini"
llm_api_key: str = ""
llm_base_url: str = ""
max_tool_calls: int = 5
request_timeout: float = 60.0
system_prompt: str = "你是一个乐于助人的智能助手。"
settings = Settings()
这里用 Pydantic Settings 的主要原因是它能自动读取环境变量和 .env 文件,还能做类型校验。你只要设置一个 LLM_MODEL 环境变量,它就会自动覆盖默认值,不需要任何额外代码。
对应的 .env.example 文件:
dotenv复制LLM_PROVIDER=openai
LLM_MODEL=gpt-4o-mini
LLM_API_KEY=sk-xxxxxxxxxxxxxxxx
LLM_BASE_URL=
MAX_TOOL_CALLS=5
REQUEST_TIMEOUT=60.0
SYSTEM_PROMPT=你是一个乐于助人的智能助手。
在 .gitignore 里加上 .env,防止 API Key 被提交到仓库。关于 API Key 还有一个坑,我后面在问题排查部分会专门讲。
3.3 LLM 接入层:统一模型调用
接下来实现模型接入层。在 app/core/llm.py 中:
python复制from typing import Any
from litellm import acompletion
from app.core.config import settings
async def chat(
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
) -> Any:
"""统一模型调用入口,支持工具调用的多轮对话。"""
kwargs: dict[str, Any] = {
"model": settings.llm_model,
"messages": messages,
"timeout": settings.request_timeout,
}
if tools:
kwargs["tools"] = tools
response = await acompletion(**kwargs)
return response.choices[0].message
这段代码看起来简单,但它就是整个骨架的“水龙头”。后面所有 Agent 能力都从这个函数过水。接口里保留 tools 参数很关键,因为 Agent 的核心能力就是模型能按需调用工具,这个参数就是给模型“递工具清单”的通道。
LiteLLM 的 acompletion 是异步补全接口,支持流式和非流式。这里用非流式,是因为骨架阶段优先保证逻辑简单可控。等你想做流式输出时,再加一个参数即可,不需要改动调用方的任何代码。
3.4 工具注册协议:让 Agent 真正“会动手”
这是整个骨架最核心的部分。一个 Agent 要解决实际问题,光靠模型本身聊天能力是不够的,它得能查天气、查库存、调用内部系统。工具注册协议就是连接“模型决策”和“程序执行”的桥梁。
在 app/agent/tools.py 中实现一个轻量的注册器:
python复制import json
from typing import Any, Awaitable, Callable
ToolHandler = Callable[..., Awaitable[Any]]
class ToolRegistry:
def __init__(self) -> None:
self._schemas: dict[str, dict[str, Any]] = {}
self._handlers: dict[str, ToolHandler] = {}
def register(
self, name: str, description: str, parameters: dict[str, Any]
) -> Callable[[ToolHandler], ToolHandler]:
def decorator(func: ToolHandler) -> ToolHandler:
self._schemas[name] = {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": parameters,
},
}
self._handlers[name] = func
return func
return decorator
def schemas(self) -> list[dict[str, Any]]:
return list(self._schemas.values())
async def dispatch(self, name: str, arguments: str) -> Any:
handler = self._handlers.get(name)
if handler is None:
raise ValueError(f"未知工具: {name}")
args = json.loads(arguments)
return await handler(**args)
registry = ToolRegistry()
然后注册一个示例工具:
python复制@registry.register(
"get_time",
"获取指定城市的当前时间",
{
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名,比如北京"}
},
"required": ["city"],
},
)
async def get_time(city: str) -> str:
# 实际项目里这里会去查时间服务,这里直接返回模拟数据
return json.dumps({"city": city, "time": "2025-01-01 12:00:00"}, ensure_ascii=False)
这个设计的一个关键点是:模型和工具之间通过 JSON 通信,模型看到的是 JSON Schema(工具的“说明书”),程序执行时再把 JSON 参数解析出来,调用真正的 Python 函数。这样模型侧不关心工具是怎么实现的,工具侧也不关心模型是哪个厂家。你就是换一万个模型,工具代码一个字都不用改。
注册器还有一个隐藏价值:它天然就是工具的文档和清单。以后想列出当前系统有哪些工具可用,直接调 schemas() 就能拿到全部工具定义,方便做监控、审计和调试。
3.5 服务接口层:把 Agent 能力暴露成 HTTP 接口
骨架最终是要被别人调用的,通过 HTTP 接口暴露是最通用的方式。在 app/schemas/chat.py 里定义接口模型:
python复制from pydantic import BaseModel, Field
class ChatRequest(BaseModel):
user_message: str = Field(..., description="用户输入")
session_id: str = Field(default="default", description="会话 ID,用于后续扩展多轮记忆")
class ChatResponse(BaseModel):
reply: str = Field(..., description="Agent 的最终回复")
tool_calls: list[str] = Field(default_factory=list, description="本次请求实际调用的工具")
然后写路由,在 app/api/routes.py:
python复制from fastapi import APIRouter
from app.agent.engine import run_agent
from app.schemas.chat import ChatRequest, ChatResponse
router = APIRouter()
@router.post("/chat", response_model=ChatResponse)
async def chat_endpoint(req: ChatRequest) -> ChatResponse:
reply, tool_calls = await run_agent(req.user_message)
return ChatResponse(reply=reply, tool_calls=tool_calls)
这里特别注意,路由只做“接参数、调引擎、返回结果”三件事,业务逻辑全部下沉到 run_agent 里。等你以后想再加 WebSocket、加流式响应、加消息队列,直接在这个路由层扩展就行,不用动 Agent 核心代码。
在 app/main.py 组装应用:
python复制from fastapi import FastAPI
from app.api.routes import router
app = FastAPI(title="Agent Skeleton", version="0.1.0")
app.include_router(router)
@app.get("/health")
async def health_check() -> dict[str, str]:
return {"status": "ok"}
健康检查接口是服务化之后最基本的要素,部署到容器里做存活探针、负载均衡做健康判断都靠它。
3.6 Agent 引擎:把循环转起来
现在到了整个骨架的心脏:Agent 循环。在 app/agent/engine.py 中:
python复制import json
from app.agent.tools import registry
from app.core.config import settings
from app.core.llm import chat
SYSTEM_PROMPT = settings.system_prompt
async def run_agent(user_message: str) -> tuple[str, list[str]]:
messages: list[dict] = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_message},
]
tool_calls_log: list[str] = []
for _ in range(settings.max_tool_calls):
message = await chat(messages, tools=registry.schemas())
if message.tool_calls:
messages.append(
{
"role": "assistant",
"content": message.content or "",
"tool_calls": [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in message.tool_calls
],
}
)
for tc in message.tool_calls:
tool_calls_log.append(tc.function.name)
result = await registry.dispatch(tc.function.name, tc.function.arguments)
messages.append(
{
"role": "tool",
"tool_call_id": tc.id,
"content": result if isinstance(result, str) else json.dumps(result),
}
)
else:
return message.content or "", tool_calls_log
return "抱歉,我已经尝试了多次仍未能完成任务,建议把问题拆得更细一些。", tool_calls_log
这个循环是整个骨架的灵魂,我用大白话解释一下它干了什么:
第一步,把系统提示词和用户消息拼成对话消息列表。第二步,把全部工具 Schema 交给模型,问它“你想调用哪个工具、参数是什么”。第三步,如果模型返回了工具调用请求,骨架就把这个请求追加到消息历史里,然后执行对应的工具函数,把工具返回的结果也追加到消息历史里,再回到第二步,让模型看到工具结果后继续决策。第四步,如果某轮模型没有返回工具调用,说明它认为任务已经搞定了,就直接把它的回复返回给用户。
循环上限至关重要。没有 max_tool_calls 做护栏,遇到一个复杂的任务,模型可能会陷入无限的工具调用循环,几分钟内烧掉大量 token,还会让服务卡死。设置成 5 是一个比较稳妥的起步值,实际项目可以根据任务复杂度调高或调低。
3.7 启动验证:30 分钟内跑通全流程
把上面这些文件配齐后,启动服务:
bash复制cp .env.example .env
# 编辑 .env,填入真实的 LLM_API_KEY
uvicorn app.main:app --reload --port 8000
然后开一个新的终端,模拟一个用户请求:
bash复制curl -X POST http://localhost:8000/chat \
-H "Content-Type: application/json" \
-d '{"user_message": "北京现在几点?"}'
如果一切正常,你会收到类似这样的响应:
json复制{
"reply": "北京的当前时间是2025-01-01 12:00:00。",
"tool_calls": ["get_time"]
}
这个简单的结果背后,完整走了一遍 Agent 循环:模型识别到用户问的是时间、匹配到 get_time 工具、解析出北京这个参数、程序执行工具、把结果交回给模型组织语言、最终返回给用户。到这一步,你的 Agent 服务骨架就算是跑通了。
我建议你先把服务跑起来,再回头看代码,对照上面的响应理解每个环节发生了什么。理解了整条链路,后面加再复杂的能力都只是在这个管道上挂新节点的事。
4. 常见问题与排查技巧实录
4.1 模型响应超时或一直报错
这是 Agent 开发里最常见的坑,搜索引擎里热词 “the agent execution provider did not respond in time” 说的就是类似场景:模型供应商响应太慢或者直接没响应。超时问题在 Agent 项目里比普通后端项目更致命,因为一次请求里可能有多轮模型调用,任何一轮超时都会让整个请求失败。
排查思路分三步:先确认网络连通性,直接 curl 一下模型供应商的接口看通不通;再确认超时设置,REQUEST_TIMEOUT 默认 60 秒,如果模型本身就慢或者网络不稳定,适当调大;最后加重试机制。真实项目中,单次模型调用失败是很正常的,骨架里推荐用 tenacity 这类库给 chat 函数加一个指数退避重试,后续我会在扩展部分讲到。
4.2 API Key 不生效或者总是报 401
这个问题九成是环境变量没加载对。Pydantic Settings 默认从当前工作目录找 .env 文件,如果你用 uvicorn app.main:app 启动时工作目录不在项目根目录,就会读不到配置。解决方式是明确指定 env 文件路径,或者干脆用环境变量注入。
还有一个高频问题:改了 .env 之后服务没重启。.env 的修改对 --reload 模式的 uvicorn 不一定触发重载,因为 uvicorn 默认只监听 .py 文件变更。所以改完 .env 后手动重启服务,这是一个不值得你花一个小时去踩的坑。
4.3 工具调用时参数格式对不上
模型返回的 arguments 是 JSON 字符串,注册器里用 json.loads 解析。如果模型生成的是不合法的 JSON,解析就会抛异常。这个问题在复杂参数场景下特别容易出现。我在生产环境里见过模型把布尔值传成字符串、把整数传成浮点数、甚至漏掉必填参数。
两个缓解手段:第一,工具参数的 JSON Schema 定义要尽量严格,description 字段要写清楚每个参数的类型和含义,模型越明白你的要求,越不容易出错;第二,在 dispatch 方法里对参数做一层容错,解析失败时返回一个友好的错误信息给模型,让它自己“消化”错误重新生成参数,而不是直接让整个请求崩溃。这也是 Agent 项目跟普通后端很不一样的地方:错误不是终点,而是反馈循环的一部分。
4.4 服务能启动但响应一直是默认的兜底文案
如果返回的是“抱歉,我已经尝试了多次仍未能完成任务”,说明 Agent 循环把 max_tool_calls 次机会都用完了,但模型还在不停调用工具。可能是因为你的工具定义有歧义,模型不知道什么时候该停;也可能是因为工具返回的结果格式过于复杂,模型一直尝试用某种方式处理它。
调试时,建议在循环里加一个打印,把每一轮的消息都打出来看看:
python复制print(f"=== Round {i} ===")
print(json.dumps(messages, ensure_ascii=False, indent=2))
这个临时日志会非常直观地告诉你模型到底在想什么、卡在哪个环节。看完之后把打印删掉或者换成 logging。
4.5 上下文长度撑爆 token 上限
Agent 多轮循环最隐蔽的问题是消息列表不断膨胀。每调用一次工具,就会新增 assistant 消息和 tool 消息,循环多的时候一轮请求就可能消耗几万 token。当用户输入本身就长,或者工具返回结果很大时,模型接口直接报 context length exceeded。
骨架阶段可以先通过 max_tool_calls 控制循环次数,同时让工具返回尽量精简的信息。比如工具只需要返回状态码和关键字段,不要返回一大段 HTML 或完整日志。后续要支持复杂场景,就需要引入记忆管理和消息压缩模块,这个就是下一阶段的事了。
5. 从骨架到真正可用:还有哪些路要走
5.1 骨架能做什么、还缺什么
当前这个骨架跑通的是 Agent 的最小闭环,它能对话、能调用工具、能通过 HTTP 接入业务系统。但它还不具备生产级 Agent 服务应该有的几个能力。
第一是记忆。当前骨架是无状态的,所有上下文都在这一个请求内,用户下次再来就什么都不记得了。要支持真正的多轮对话,需要一个会话存储,把历史消息按 session 存下来,每次请求时加载。
第二是可观测性。生产环境里的 Agent 比普通接口难调试得多,你根本不知道模型内部做了哪些决策、为什么调用那个工具。建议给骨架加上日志追踪,至少把每轮的输入输出、工具调用、耗时记录下来。
第三是流式输出。非流式响应适合骨架验证,但真实用户体验非常差,一个复杂的任务生成可能要等十几秒。建议后续接入 SSE,让 AI 的回复一个字一个字地“打出来”。
第四是权限控制。Agent 一旦接入了真实业务工具,就等于给了模型一把能操作内部系统的钥匙,权限边界、操作审计、敏感操作二次确认,都是不可回避的设计。
5.2 二次开发和技术演进方向
骨架跑通之后,往哪个方向扩展取决于你的业务场景。我讲几个常见的方向和做法。
如果要接入企业私有知识库做 RAG,可以在 agent 层加一个 knowledge.py 模块,封装向量检索能力,然后注册成工具就行。模型看到用户问题后,先调用检索工具拿到相关资料,再基于资料回答。
如果要做多 Agent 协作,可以把每个 Agent 都设计成一个独立服务,靠消息队列或者事件总线互相通信。骨架里 registry 的设计在单 Agent 内部用已经很顺手,但跨 Agent 的协议需要单独设计,建议从一开始就定好消息格式,避免后期返工。
如果要做定时任务、异步任务型 Agent,那 HTTP 同步接口就不够用了。可以引入任务队列,把用户请求先塞进队列,Agent 在后台异步处理,处理完把结果写到存储,前端轮询或者通过 WebSocket 推送结果。
5.3 一些我踩过的坑和最终建议
带过几个 Agent 项目之后,我最大的体会是:Agent 开发的门槛不在模型调用,而在工程化。模型的能力大家都在同一起跑线,拉开差距的是谁能把工具链路做得更稳、上下文管理做得更好、异常恢复做得更完善。骨架阶段多花点时间把基础打牢,后面会省下数倍的时间。
如果你还没开始,就把这篇文章里的代码照着敲一遍,不要复制粘贴,手敲一遍能帮你理解很多细节。敲完之后,试着往注册器里加你自己的业务工具,把一个真实场景跑通,这比看一百篇文章都管用。
最后再分享一个小技巧:保持配置的文件化。模型名称、系统提示词、API Key、循环次数、超时时间,全部放进配置里,不要硬编码在代码中。坚持一段时间后你会发现,调优 Agent 行为时改配置比重写代码效率高得多,而且不会把自己逼进“改一行代码、牵连三个文件”的窘境。这个习惯,越早养成越好。
