简介:本文不追求“让模型说一句话”,而是演示如何把一个 Python Agent 能力封装成可被后端服务调用的最小工程结构。你将看到配置管理、DTO 设计、客户端隔离、服务编排、安全护栏、轻量 RAG 与测试反馈如何同时落地,并通过分阶段验证降低调试成本。
很多开发者第一次接触智能体开发时,习惯在脚本里直接调用模型接口,输入一句话,拿到一句回复,然后认为“已经会了”。这种方式适合验证模型可用性,但距离真正可被业务系统消费的 Agent 服务还有很大差距。
一个可被后端调用的 Agent 服务至少需要回答几个问题:服务如何启动和停止?如何暴露健康检查?如何接收结构化请求并返回一致响应?模型配置从哪里读取?当用户输入存在风险时如何处理?知识检索和工具调用如何进入主链路?没有真实模型密钥时如何测试?
本文的目标就是把这些工程问题压缩到一个最小但完整的 Python 服务中。服务本身不依赖特定模型厂商,所有模型调用通过一层客户端抽象完成;默认测试使用假客户端,真实模型测试必须显式开启。这样既保证学习过程可复现,也避免误调线上接口。
整个服务围绕一个轻量 HTTP 框架组织,但核心逻辑不绑定框架细节。目录结构可以这样设计:
agent-service/├── app/│ ├── __init__.py│ ├── config.py│ ├── dto.py│ ├── clients/│ │ ├── __init__.py│ │ ├── base.py│ │ ├── fake.py│ │ └── real.py│ ├── services/│ │ ├── __init__.py│ │ ├── guard.py│ │ ├── rag.py│ │ ├── agent.py│ │ └── today.py│ └── routes.py├── tests/│ ├── test_health.py│ ├── test_chat.py│ └── test_rag.py├── requirements.txt└── README.md
这个结构并不复杂,但它把关注点分离得足够清楚:配置只存在于 config.py;请求和响应结构集中在 dto.py;所有外部模型访问都通过 clients 层隔离;业务编排放在 services 层;路由只负责协议适配。
配置管理容易被忽视。如果模型地址、密钥、开关散落在代码各处,后续维护会非常痛苦。我们采用集中式配置对象,并使用环境变量覆盖默认值。
import osfrom dataclasses import dataclass@dataclass(frozen=True)class Settings:app_name: str = "minimal-agent-service"model_provider: str = "fake"model_name: str = "fake-model"api_key: str = ""base_url: str = ""use_real_model: bool = Falserag_chunk_size: int = 500rag_top_k: int = 3@classmethoddef from_env(cls) -> "Settings":return cls(model_provider=os.getenv("MODEL_PROVIDER", "fake"),model_name=os.getenv("MODEL_NAME", "fake-model"),api_key=os.getenv("API_KEY", ""),base_url=os.getenv("BASE_URL", ""),use_real_model=os.getenv("USE_REAL_MODEL", "false").lower() == "true",rag_chunk_size=int(os.getenv("RAG_CHUNK_SIZE", "500")),rag_top_k=int(os.getenv("RAG_TOP_K", "3")),)
关键点是 use_real_model 必须显式设置为 true 才会启用真实调用。默认情况下,服务运行在“安全模式”,所有模型相关测试不会产生外部请求。这在开发早期尤其重要,可以避免因为配置错误导致的意外调用。
HTTP 层适合传递 JSON,但服务内部如果直接操作字典,会很快失去类型约束。我们定义简单的数据类来描述请求和响应。
from dataclasses import dataclass, fieldfrom typing import Any, Dict, List, Optional@dataclassclass ChatRequest:message: struser_id: Optional[str] = Nonesession_id: Optional[str] = None@dataclassclass ChatResponse:reply: strguarded: bool = Falseretrieved_chunks: List[Dict[str, Any]] = field(default_factory=list)tool_calls: List[Dict[str, Any]] = field(default_factory=list)model: str = ""@dataclassclass HealthResponse:status: strmodel_provider: strmodel_name: struse_real_model: bool
这样做的好处是:路由层只负责把 JSON 转换成 DTO;服务层只处理 DTO;测试中构造对象也更直观。后续如果增加字段,只需在数据类中修改,影响面可控。
模型客户端是服务中最容易产生外部依赖的部分。我们定义一个极简抽象,然后提供两个实现:假客户端用于测试,真客户端用于真实调用。抽象可以只保留一个 chat 方法。
from typing import Any, Dict, Listclass BaseModelClient:def chat(self,messages: List[Dict[str, str]],context: str = "",tools: List[Dict[str, Any]] = None,) -> Dict[str, Any]:raise NotImplementedError
假客户端返回确定性内容,方便断言:
class FakeModelClient(BaseModelClient):def chat(self, messages, context="", tools=None):return {"content": f"fake reply for: {messages[-1]['content']}","tool_calls": [],}
真客户端则通过通用 HTTP 方式调用模型服务,具体地址、密钥和模型名从配置读取。实现时尽量避免绑定某个厂商的 SDK,只使用标准 HTTP 库和通用消息格式。这样当模型提供方变化时,只需调整配置和适配逻辑,不影响上层编排。
用户消息不能直接进入模型。最小可行做法是在服务编排前设置输入护栏,处理明显不适合处理的内容:例如超长输入、空消息、包含明确越权指令或敏感操作的请求。
class InputGuard:def __init__(self, max_length: int = 2000):self.max_length = max_lengthdef check(self, message: str) -> tuple[bool, str]:if not message or not message.strip():return False, "empty message"if len(message) > self.max_length:return False, "message too long"lowered = message.lower()blocked_phrases = ["ignore previous instructions", "system prompt"]for phrase in blocked_phrases:if phrase in lowered:return False, "prompt injection detected"return True, "ok"
护栏不是安全审计的全部,但它提供了一个明确的位置来集中管理基础规则。后续可以在这里增加更复杂的检测逻辑,例如敏感信息识别或异常模式匹配,而不必修改主业务流程。
即使是最小版本,Agent 也不应只依赖模型参数记忆。我们引入一个极简 RAG 存储:将文本按块切分,存储到内存中,并在对话时做相似度检索。这里不依赖外部向量数据库,只使用通用文本相似度计算。
import refrom typing import List, Dictclass MinimalRagStore:def __init__(self, chunk_size: int = 500):self.chunk_size = chunk_sizeself.chunks: List[str] = []def ingest(self, text: str) -> int:normalized = re.sub(r"\s+", " ", text).strip()chunks = [normalized[i:i + self.chunk_size]for i in range(0, len(normalized), self.chunk_size)]self.chunks.extend(chunks)return len(chunks)def search(self, query: str, top_k: int = 3) -> List[Dict[str, any]]:if not self.chunks:return []query_terms = set(query.lower().split())scored = []for idx, chunk in enumerate(self.chunks):chunk_terms = set(chunk.lower().split())score = len(query_terms & chunk_terms)scored.append((idx, chunk, score))scored.sort(key=lambda x: x[2], reverse=True)return [{"index": idx, "text": chunk, "score": score}for idx, chunk, score in scored[:top_k] if score > 0]
检索结果会作为上下文拼接到主模型请求中。这样做有两个目的:一是验证 RAG 链路是否通顺;二是让后续替换更复杂的检索方案时,接口保持不变。
工具调用在最小版本中可以先实现为一个固定工具注册表,例如“获取今日日期”“查询用户打卡状态”。由于默认使用假客户端,工具调用不会真实执行;但在真客户端模式下,工具定义会随请求一起传递,并通过返回的调用信息在服务层执行对应函数。
AgentService 是核心编排层。它会依次完成:检查输入、检索上下文、决定是否调用工具、准备消息、调用模型客户端、组装统一响应。
class AgentService:def __init__(self, client, guard, rag_store, tools=None):self.client = clientself.guard = guardself.rag_store = rag_storeself.tools = tools or []def chat(self, message: str) -> ChatResponse:allowed, reason = self.guard.check(message)if not allowed:return ChatResponse(reply=f"输入被拦截: {reason}", guarded=True)chunks = self.rag_store.search(message)context = "\n".join([c["text"] for c in chunks])messages = [{"role": "system", "content": "你是一个受控助手。"},{"role": "user", "content": message},]result = self.client.chat(messages=messages,context=context,tools=self.tools,)return ChatResponse(reply=result.get("content", ""),retrieved_chunks=chunks,tool_calls=result.get("tool_calls", []),model=getattr(self.client, "model_name", ""),)
这里的关键不是逻辑多么复杂,而是每一段职责都很清楚。后续如果需要增加多轮记忆、异步处理或流式返回,都可以在对应位置扩展,而不需要重写整个服务。
HTTP 路由层把上述能力暴露为接口。/health 返回当前配置和模型模式,方便部署时做探活;/agent/chat 接收用户消息并返回 ChatResponse;/agent/today 基于用户档案和打卡记录生成今日计划;/rag/ingest 接收文本并写入 RAG 存储。
def register_routes(app, settings, agent_service, today_service, rag_store):@app.get("/health")def health():return {"status": "ok","model_provider": settings.model_provider,"model_name": settings.model_name,"use_real_model": settings.use_real_model,}@app.post("/agent/chat")def chat(req: ChatRequest):return agent_service.chat(req.message)@app.post("/agent/today")def today(req: TodayRequest):return today_service.build_plan(req.user_id)@app.post("/rag/ingest")def ingest(req: IngestRequest):count = rag_store.ingest(req.text)return {"ingested_chunks": count}
/health 的价值不只是返回 200。它把模型模式和配置暴露出来,使运维人员可以快速确认当前环境是安全模式还是真实模式,避免在错误环境中排查问题。
本文强调分阶段验证。每完成两到四个文件,就运行一次轻量测试,而不是等全部代码写完再调试。以下是一个典型的阶段划分:
第一阶段:配置与 DTO。验证配置可以从环境变量正确读取,DTO 可以正常实例化。
第二阶段:假客户端与输入护栏。验证假客户端返回确定性内容,护栏能拦截空消息和超长消息。
第三阶段:RAG 存储。写入一段文本,确认切片数量正确,检索能返回相关块。
第四阶段:服务编排与路由。使用测试客户端发送请求,确认 /health、/agent/chat、/rag/ingest 均正常。
这种做法的核心收益是:错误一旦出现,只可能出现在最近两到四个文件之间,范围很小,定位很快。
启用真实模型时,不需要修改服务编排和路由,只需要在环境变量中显式设置:
export USE_REAL_MODEL=trueexport MODEL_PROVIDER=realexport MODEL_NAME=你的模型名export API_KEY=你的密钥export BASE_URL=模型服务地址
然后运行服务。此时 clients.real 会代替 clients.fake,并由真实模型完成回复。由于所有测试默认使用假客户端,在开发环境中可以随时运行测试套件而不产生外部调用。
完成这个结构之后,你得到的不是一个“会说话的脚本”,而是一个可以持续演进的 Agent 服务骨架。它解决了几个实际问题:第一,模型能力被封装在明确边界内,替换成本低;第二,输入风险和运行模式有基本控制;第三,RAG 和工具链路不是概念,而是真实可调的代码;第四,测试可以在无外部依赖的情况下运行;第五,后端服务可以通过 HTTP 接口直接集成。
后续如果要继续演进,可以围绕这个骨架逐步增加多轮会话、持久化检索、更完善的工具执行器、异步队列、流式输出和可观测性。每一步都能在现有结构中自然生长,而不是推倒重来。
这就是“最小可工程化 Agent 服务”的意义:它足够小,能在一个章节中讲完;也足够完整,能支撑后续的严肃开发。