接到Agent项目,最常见的翻车点往往不是模型效果拉胯,也不是工具能力不够,而是项目一开始就没搭好骨架。我之前带过好几个Agent项目,发现大家普遍走两个极端:一是选了个大而全的框架,对着文档研究两三天,最后发现80%的能力都没用上;二是完全从零手写,目录越写越乱,日志不知道在哪看,工具注册靠复制粘贴,换一个需求又得重构一遍。
所以这篇文章我想分享一套自己在实战里沉淀下来的Agent服务骨架搭建方法论:不依赖某个重框架,用最朴素的Python组件,按一条清晰的路径,把LLM调用、主循环、工具注册、记忆、日志、HTTP入口全部串起来。按照这个思路走,30分钟搭一个能跑通"用户提问->Agent思考->调用工具->返回结果"全流程的服务骨架是完全可以做到的,后面加业务逻辑、接向量库、拆多Agent,都是在骨架上填肉的事。
这篇文章适合几类人:刚入门Agent开发、被各种概念绕晕的初学者,想快速起一个可运行项目做技术验证的开发者,以及被现有框架束缚想自己掌控主循环的进阶玩家。我会把每一步的"为什么"也讲清楚,不讲废话,给可以直接抄的代码和目录结构。
1. 开工前先想明白:Agent服务骨架到底要解决什么问题
很多人一开始就把Agent想复杂了,上来就打算接向量数据库、设计多Agent协作、搞持久化记忆。这些当然有价值,但骨架阶段最核心的任务只有一个:让"用户输入一句话,Agent自主完成多步推理和工具调用,最终给出答案"这个闭环稳定地转起来。其他一切都是在这个环上扩展。
1.1 从一次Demo翻车说起
有次我给一个内部项目做技术预研,当时没用骨架直接写业务逻辑。第一天很爽,一个Python脚本里塞了LLM调用、工具函数、prompt模板。第二天要加日志,发现所有print都混在一起;第三天要接新的数据源工具,不得不把工具函数从一个文件搬到另一个文件;第四天要暴露HTTP接口给前端,结果业务逻辑和web框架耦合在一起,改一处崩三处。这个Demo最后花了将近两周才稳定,大部分时间都浪费在"重建结构"上,而不是"实现功能"上。
那次之后我明白了一件事:Agent项目和传统Web项目不一样,它天然包含几个职责完全不同的模块,如果不在第一天就切分开,后面一定会付出几倍的返工代价。
1.2 Agent服务骨架的最小组成清单
一个能"跑起来"的Agent服务骨架,至少要包含下面六块,缺一块后面都会补得很痛苦:
| 模块 | 解决什么问题 | 骨架阶段的最简形态 |
|---|---|---|
| LLM客户端封装 | 统一模型调用、处理流式/非流式 | 一个支持OpenAI协议接口的封装类 |
| 主循环(Agent Loop) | 自主推理、多步工具调用 | for循环+终止条件,100行左右 |
| 工具注册中心 | 让Agent知道有哪些工具、怎么调用 | 装饰器+全局注册表 |
| 记忆模块 | 保存上下文、多轮对话 | 内存版Messages列表 |
| 配置管理 | 模型名、API Key、参数不写死在代码里 | pydantic+yaml/.env |
| 日志与可观测性 | 能追溯Agent每一步在干什么 | logging模块+标准日志格式 |
骨架阶段最忌讳的是什么都上重型方案。向量库、任务编排引擎、消息队列,这些在骨架期都是负担不是助力。先把上面六个模块用最直接的方式串起来,跑通一次完整的工具调用闭环,再谈优化和扩展。
1.3 技术选型:为什么是Python + FastAPI而不是其他
选技术栈的时候我其实纠结过,最后用Python + FastAPI,理由是:
Python系大模型生态最全,Pydantic可以做参数校验,OpenAI SDK和各大模型厂商的SDK都对Python最友好。 FastAPI相比Flask和Django,支持异步、自带OpenAPI文档、天然的Pydantic集成,用来做Agent服务的HTTP入口非常合适。选型这个事没有绝对标准,但骨架阶段的核心诉求是"不折腾、好扩展、生态全",这套组合在2026年的Agent开发生态里,依然是摩擦最小的路径。
还有个容易被忽略的细节:选型时要把"未来接MCP协议"或者"接不同厂家的模型"考虑进去,但不要在第一版就把抽象层级做得太深。 一层接口一层实现没问题,三层以上就属于过度设计了。我见过不少项目在const层之上又套了两层抽象,最后连自己都找不到在哪改代码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 30分钟倒计时:目录结构与依赖搭建顺序
我习惯按"职责边界"来组织目录,而不是按文件类型。一个合理的Agent服务目录,应该让人在10秒内判断出"这个模块属于哪个层、该去哪改代码"。
2.1 目录结构:按职责划分,不按文件类型堆
我建议的骨架目录长这样:
code复制agent-skeleton/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI入口,HTTP层
│ ├── config.py # pydantic配置,读环境变量和yaml
│ ├── schemas.py # 请求/响应模型
│ ├── agent/
│ │ ├── __init__.py
│ │ ├── loop.py # Agent主循环
│ │ ├── llm.py # LLM客户端封装
│ │ └── memory.py # 记忆模块(先做内存版)
│ └── tools/
│ ├── __init__.py
│ ├── registry.py # 工具注册中心
│ └── builtin.py # 内置工具,比如计算器、时间查询
├── config/
│ └── config.yaml # 默认配置
├── logs/
├── tests/
│ └── test_loop.py
├── .env.example # 环境变量模板
├── requirements.txt
└── README.md
注意几个关键点:
- agent目录和tools目录平级,配置独立,HTTP层在最外面。 这样设计的原因是:主循环不依赖FastAPI,工具不依赖任何Web框架,你可以在命令行里直接跑Agent,也可以挂到HTTP服务里,甚至可以放到消息队列的worker里消费任务。
logs/目录在骨架阶段就要建好,别等出问题了再补日志。tests/目录不用写多少用例,但至少要有一个测试主循环能跑通的smoke test,防止改着改着骨架就坏了。
2.2 依赖安装:先跑通再优化
requirements.txt在骨架阶段只放最核心的一批依赖:
code复制openai>=1.40.0 # 大模型调用,兼容OpenAI协议
fastapi>=0.115.0 # HTTP服务
uvicorn[standard]>=0.30.0
pydantic>=2.7.0
pydantic-settings>=2.3.0
pyyaml>=6.0
python-dotenv>=1.0.1
httpx>=0.27.0 # 工具调用里可能需要发HTTP请求
先列这些就够了。向量库SDK、Agent框架、任务队列,一个都不要加。跑通主循环之后,你会很清楚地知道自己缺什么,那时候再加依赖是"按需加",比现在"预防性加一堆"要靠谱得多。依赖这个东西,每多一个就多一层版本冲突和工作量,骨架期应该做减法。
2.3 配置管理:yaml + pydantic 的组合
配置管理这块,我踩过"配置散落在各文件"的坑,也踩过"配置全靠环境变量"的坑。最后沉淀的做法是默认值放到yaml,敏感信息走.env,代码里用pydantic-settings统一读取。
config.yaml里放非敏感的默认值:
yaml复制llm:
model: "gpt-4o-mini"
temperature: 0.7
max_tokens: 2048
base_url: "https://api.openai.com/v1"
timeout_seconds: 60
agent:
max_steps: 10
system_prompt: "你是一个有用的智能助手,请用中文回答问题。"
然后在config.py里用Pydantic定义配置模型:
python复制from pathlib import Path
from typing import Optional
from pydantic import BaseModel
import yaml
class LLMConfig(BaseModel):
model: str = "gpt-4o-mini"
temperature: float = 0.7
max_tokens: int = 2048
base_url: str = "https://api.openai.com/v1"
timeout_seconds: int = 60
class AgentConfig(BaseModel):
max_steps: int = 10
system_prompt: str = "你是一个有用的智能助手,请用中文回答问题。"
class Settings(BaseModel):
llm: LLMConfig
agent: AgentConfig
log_level: str = "INFO"
@classmethod
def load(cls, path: str = "config/config.yaml") -> "Settings":
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
return cls(**data)
至于API Key这种敏感信息,我不会放进yaml,而是通过环境变量注入到代码里,比如在llm.py里直接os.getenv("OPENAI_API_KEY")读取,.env.example里写清楚需要哪些变量。
这个配置方案的核心价值是:换环境、换模型、调参数,都不需要动业务代码。 我见过很多项目把model名和temperature直接硬编码在循环里,每调试一次就改一次代码,那是真的浪费时间。
3. Agent主循环:骨架的心脏是怎么转起来的
如果你理解了Agent主循环,基本上就拿到了Agent开发最核心的那把钥匙。网上现在流行叫"Agent Loop"或"Agent Cycle",很多人觉得这是个高深的概念,其实拆开看就是一个"思考-行动-观察"的循环。
3.1 每一轮循环里发生了什么
一次完整的Agent交互,流程是这样的:
- 用户输入问题,拼上系统提示词,组成初始消息列表。
- 把消息列表发给LLM,同时把工具的JSON Schema也传过去。
- LLM返回两种结果之一:要么是最终答案文本,要么是一个工具调用请求。
- 如果来了工具调用请求,主循环就解析请求里的函数名和参数,去工具注册中心执行对应的函数。
- 把工具执行结果作为一条"tool"消息放回消息列表。
- 带着更新后的消息列表再回到第2步,继续让LLM推理。
- 直到LLM不再请求工具调用、直接返回文本,或者达到最大轮数,循环终止。
这个循环很像"一个员工在做事":老板(用户)下达任务,员工(LLM)思考后决定"我需要查一下数据库",查到结果后继续思考,再决定"我还需要调用一个API",直到攒够信息,给出最终汇报。Agent的"智能"本质上就体现在这个多步推理和工具调用的交替过程里。
3.2 终止条件与轮数控制
主循环设计里最容易出bug的是终止条件。我见过不少Agent卡死的情况,基本都是因为循环没有明确的出口。
必须设计的终止条件至少有三个:
- LLM直接返回最终答案(没有tool_calls字段)说明它认为任务完成了,这是最自然的出口。
- 最大循环步数,比如10步,防止Agent在某个问题上反复横跳、无限调用工具。这一步也是控制费用的关键。
- 单轮超时时间,比如LLM请求60秒没响应就抛错,防止服务挂起。
在骨架阶段,这三个条件缺一不可。尤其是最大步数,我强烈建议宁可设小一点(比如8~10步),也别设成50步——Agent在复杂任务里浪费token的速度,远比你想的快。
3.3 一个可直接运行的主循环代码示例
下面这个loop.py是我压到最小可用状态的一个版本,核心逻辑全部保留,你可以在自己的项目里直接用:
python复制import json
import logging
from typing import TypedDict
logger = logging.getLogger(__name__)
class ToolRegistry:
"""工具注册中心:负责管理Agent可用的所有工具。"""
def __init__(self):
self._funcs = {}
self._schemas = []
def register(self, name: str, description: str, parameters: dict):
"""注册一个工具。"""
def decorator(func):
self._funcs[name] = func
self._schemas.append({
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": parameters,
},
})
return func
return decorator
@property
def schemas(self) -> list:
return self._schemas
def execute(self, name: str, arguments: str):
try:
parsed_args = json.loads(arguments) if isinstance(arguments, str) else arguments
func = self._funcs[name]
result = func(**parsed_args)
return json.dumps(result, ensure_ascii=False)
except Exception as e:
logger.exception("工具 %s 执行失败: %s", name, e)
return json.dumps({"error": str(e)}, ensure_ascii=False)
class AgentLoop:
def __init__(self, llm_client, tool_registry, system_prompt, max_steps=10):
self.llm = llm_client
self.tools = tool_registry
self.system_prompt = system_prompt
self.max_steps = max_steps
def run(self, user_input: str) -> str:
messages = [{"role": "system", "content": self.system_prompt}]
messages.append({"role": "user", "content": user_input})
for step in range(self.max_steps):
logger.info("第 %d 轮循环,当前消息数 %d", step + 1, len(messages))
response = self.llm.chat(
messages=messages,
tools=self.tools.schemas,
)
message = response["choices"][0]["message"]
messages.append(message)
tool_calls = message.get("tool_calls")
if not tool_calls:
logger.info("Agent 返回最终答案,循环结束")
return message.get("content", "")
for tc in tool_calls:
fn = tc["function"]
logger.info("调用工具: %s, 参数: %s", fn["name"], fn["arguments"])
result = self.tools.execute(fn["name"], fn["arguments"])
messages.append({
"role": "tool",
"tool_call_id": tc["id"],
"content": result,
})
logger.warning("达到最大步数 %d,强制终止", self.max_steps)
return "抱歉,任务处理步数超过上限,未能完成。"
注意这里有一个非常关键的细节:每轮循环都必须把LLM返回的完整message追加到messages里,包括它带的tool_calls字段。 很多新手漏掉这一步,直接把追加的内容变成普通文本,结果LLM隔一轮就忘了自己刚才要调用什么工具,整个循环逻辑就乱了。
4. 工具调用与记忆模块:让Agent从"能说话"到"会干活"
一个只有主循环的Agent只是个聊天机器人,真正的价值来自它能调用工具。工具模块设计得好不好,直接决定了Agent能做多少事。
4.1 工具定义:用函数装饰器注册最省事
我推荐用装饰器来注册工具,这样写业务工具时,只需要关注函数本身,注册流程完全隐藏在装饰器里。在tools/builtin.py里写两个内置工具做演示:
python复制import datetime
from app.tools.registry import registry # 全局单例
@registry.register(
name="get_current_time",
description="获取当前日期和时间",
parameters={
"type": "object",
"properties": {},
},
)
def get_current_time():
return {"now": datetime.datetime.now().isoformat()}
@registry.register(
name="calculator",
description="计算数学表达式,支持加减乘除、括号、幂运算",
parameters={
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "需要计算的数学表达式,例如 (3+5)*2"
}
},
"required": ["expression"],
},
)
def calculator(expression: str):
# 注意:eval有安全风险,生产环境中应使用受限的解析器
return {"result": eval(expression)} # 骨架演示用,请勿直接照搬到生产环境
这里有一个实践要点:工具的描述信息非常重要。 很多人觉得description随便写写就行,但实际上LLM是通过description来判断"什么时候该用这个工具"的。描述写得越精确,Agent对工具的选择就越准。比如calculator的描述里加一句"支持加减乘除、括号、幂运算",LLM就会在遇到复杂运算时倾向于调用它,而不是自己硬算。
4.2 工具调用的执行链路
当主循环里tools.execute(name, arguments)被调用时,工具注册中心会做这几件事:
- 根据函数名从注册表里找到对应的函数;
- 解析LLM返回的参数JSON字符串;
- 用Python的
**kwargs方式把参数传给函数; - 捕获异常并转成agent能读懂的JSON错误信息,而不是直接抛异常中断循环。
第4步特别特别重要。工具执行失败是常态,但Agent不应该因此崩溃。 把错误信息转成正常的工具返回内容,LLM看到"error"字段后会自己决定是换个参数重试、换一个工具、还是直接向用户说明失败原因。这个容错设计,能让你的Agent在真实场景下稳定不少。
4.3 记忆:先别急着上向量库
骨架阶段的记忆模块,一个内存版的Messages列表就够用了。核心目标是把"多轮对话的上下文"维护好。
不要一上来就接Chroma、pgvector这些。原因很简单:在骨架期你根本不知道自己的记忆需求形态,很可能是长期记忆(跨会话)、情景记忆(当前会话)、工作记忆(当前步骤)之间的某种比例组合。 这个需求只有在业务逻辑跑起来之后才会显现,提前设计大概率会过度设计。
骨架期唯一需要做的是:主循环里维护messages列表,并在外层把它保存在内存里。当你要做多轮对话时,简单把历史消息拼接到对话开头就行。等后面确认了需要持久化、需要语义检索,再按需接向量库,那时候你会更清楚"该把什么放进向量库、检索什么样的topK"。
顺便说一句,最近不少人问Agent Skill和MCP有什么区别。简单理解,MCP是工具调用协议层的标准,解决的是"Agent如何发现和调用外部能力"的问题;Skill则是面向复用的一套能力封装,解决的是"某一类任务怎么做更高效"的问题。 骨架阶段两个都不需要,先把基础工具注册跑通,后面再决定要不要引入这些生态标准。
5. 配置、日志与可观测性:骨架的筋络血肉
代码能跑只是第一步,一个能进生产环境的骨架必须能"看懂"自己。日志和可观测性是我在带项目时最看重的部分,因为Agent的多步推理过程不透明,一旦出错,如果没有日志,排查问题就像在黑箱里捞针。
5.1 日志:每一轮循环都要能追溯
日志设计的原则是:通过日志能完整还原一次Agent交互的全部过程。 在loop.py里我已经埋了三个关键日志点:
- 每轮循环开始时的
第 N 轮循环,当前消息数 X; - 工具调用时的
调用工具: 名称, 参数: xxx; - 循环结束原因的日志:正常收敛、最大步数、还是异常退出。
光有logging还不够,建议在最外层加上一个结构化日志处理器,把日志按JSON格式输出,这样后面接入日志收集系统(ELK/Loki)时就不用改代码了。一个简单的JSON日志格式化器大约20行,骨架阶段可以直接抄进去。
5.2 可观测性:把Agent的思考过程暴露出来
很多人分不清"日志"和"可观测性"。我自己的经验是:日志解决的是"事后排查"的问题,而可观测性解决的是"实时观察和主动告警"的问题。
骨架阶段可以先做两件事:一是把每次LLM请求的延迟和token消耗记录成指标;二是提供一个HTTP接口来查看当前记忆列表或会话状态。等系统变复杂了,再上OpenTelemetry或Langfuse这类专用工具,把每次推理过程可视化出来。
别小看这些基础埋点。Agent服务的费用问题和性能瓶颈,几乎都藏在"一次请求里调了多少次LLM""每次工具调用花了多久"这两个数字里。 没有指标,你连优化方向都找不到。
5.3 接口封装:让骨架能对上HTTP协议
最后用FastAPI把骨架包一层HTTP接口,目的是让前端、其他服务都能用标准方式请求Agent。下面是最小可用的main.py:
python复制from fastapi import FastAPI
from pydantic import BaseModel
from app.agent.loop import AgentLoop
from app.agent.llm import LLMClient
from app.tools.registry import registry
from app.tools import builtin # noqa: F401 确保工具被注册
from app.config import Settings
app = FastAPI(title="Agent Skeleton")
settings = Settings.load()
llm = LLMClient(settings.llm)
agent_loop = AgentLoop(
llm_client=llm,
tool_registry=registry,
system_prompt=settings.agent.system_prompt,
max_steps=settings.agent.max_steps,
)
class ChatRequest(BaseModel):
message: str
class ChatResponse(BaseModel):
answer: str
@app.post("/chat", response_model=ChatResponse)
def chat(req: ChatRequest):
answer = agent_loop.run(req.message)
return ChatResponse(answer=answer)
@app.get("/health")
def health():
return {"status": "ok"}
这里的一个设计细节是:将AgentLoop实例做成模块级单例,而不是在每个请求里重新创建。 因为LLMClient内部有连接池,反复创建会带来不必要的开销。但如果后面要做多租户隔离,这个单例就要改成按租户维护一个实例池,那是后话了。
启动命令一行:
bash复制uvicorn app.main:app --host 0.0.0.0 --port 8000
然后浏览器打开http://localhost:8000/docs,你就能在Swagger文档里直接测试/chat接口了。从目录搭建到这一步,熟练的话确实不到30分钟。
6. 跑通Demo之后的扩展思路与避坑经验
当你把上面这套骨架跑通,一次完整的"用户提问->Agent思考->工具调用->返回结果"闭环能正常工作了,恭喜,你已经有了一个非常结实的底座。接下来怎么扩展,我按优先级给你排个序。
6.1 从单体Agent到多Agent的演进
很多人在骨架跑通之后,立刻就想上多Agent架构。我个人的建议是:先确认你的业务真的需要多Agent吗? 大部分场景用单Agent加一堆工具就能解决,多Agent带来的收益是分工明确,但代价是维护成本、token成本、协调复杂度翻倍。
如果你确实要拆多Agent,目前主流的套路有两种:一种是主从模式,核心思想是把子Agent当作一种特殊的工具来调用——主Agent决定"我需要让财务Agent算一下预算",于是调用一个叫"财务分析子Agent"的工具,子Agent独立跑完自己的主循环后把结果作为工具返回值交还给主Agent。另一种是Router模式,先由一个路由Agent判断用户问题的类型,再转发给不同的专用Agent处理。
这两种模式有一个共同点:本质上都是把Agent当作工具来编排。 理解了这一点,你就能在现骨架的基础上做乘法,而不是推翻重来。
6.2 并发与API费用:先扣上刹车再上路
骨架能跑起来后,最容易被忽视的两个问题是并发和费用。
先看并发。FastAPI默认是同步接口,如果每个请求耗时20秒,一旦有几十个请求进来,线程池会打满,后面的请求全部排队。骨架阶段你可以先不处理,但至少要知道:LLM请求是IO密集操作,要么用异步主循环,要么用消息队列做削峰。 我建议在骨架跑通后,优先把主循环改成async版本,配合FastAPI的async接口,能扛住的并发量立刻上一个台阶。
再看费用。Agent服务的费用公式大致是:
code复制单次任务费用 = 每轮LLM调用的token费用 × 平均轮数
在骨架阶段没做任何限制的情况下,一个稍复杂的任务烧掉几十万token并不稀奇。所以至少要加三层刹车:最大轮数(已经有了)、每次请求的max_tokens上限、单任务总token预算。 第三层可以在主循环里维护一个累计token计数器,超过阈值直接终止。别等月底看账单的时候再心痛。
6.3 我踩过的一些坑(时间成本/格式解析/死循环)
最后分享一下我在搭这类骨架时真实踩过的坑,希望你能绕开:
第一个坑是LLM返回格式不稳定。 某些模型在返回tool_calls时偶尔会多出一些怪字段,或者把JSON参数格式弄坏。我之前在一个项目里就遇到过,模型把arguments字段搞成了非法JSON,导致json.loads直接抛异常。后来的处理方式是execute方法里加了一层容错:先尝试json.loads,失败就尝试用正则提取JSON片段,再失败就用AST解析Python字典。这层容错看着丑,但救了很多次场。
第二个坑是死循环。 有一次我把最大步数设成了50,结果Agent在"查询数据库->发现数据不对->再写一段Python->再查询"这个环里转了将近40轮,花了三块钱才被截断。当时的模型是一个推理能力极强但自我纠错也很强的模型,它总觉得自己下一次就能成功。后来我把最大步数改成10,并给系统提示词加了一句"如果尝试两次仍无法解决问题,请如实告知用户当前遇到的困难",这个情况就再没出现过了。
第三个坑是工具函数里用了同步的requests调用,卡死了整个线程。 在FastAPI的同步接口里,一个工具卡住,最坏情况下会占用一个工作线程直到超时。骨架阶段我习惯给所有工具设置自己的超时时间,并用functools或threading做并发限制。这里不必过度设计,但每个工具都要有超时保护的意识。
第四个坑是系统提示词写得太短。 很多Agent在跑复杂任务时会胡乱调用工具,一部分原因就是系统提示词里没告诉它"什么时候该调用工具、什么时候不该调用"。我的做法是在系统提示词里固定加一段"工具使用准则",明确说:如果用户的问题不需要外部信息,请直接回答;如果需要工具,优先选择参数最匹配的工具;工具执行失败时,尝试调整参数重试一次,不行就向用户说明。
踩过这些坑之后,我现在的原则很简单:骨架可以简单,但边界必须清晰。 主循环要有终止条件,工具要有容错,日志要能追溯,配置要可修改。这些边界不是第一版就能想全的,但骨架阶段把它们都留给固定的位置,后面填肉就不会乱。
最后再说一句:架子搭得越稳,后面加业务逻辑的速度越快。 30分钟换来后面几个星期不返工,这笔账怎么算都划算。如果你在搭的过程中遇到什么奇怪的问题,欢迎带着日志来聊,我最喜欢看那些"模型为什么非要这么干"的疑难杂症了。
