本文摘要:智能体此前只能在命令行里交互,外部程序拿不到回复,能力被锁在本地进程。新增的 api.py 用 FastAPI 把它包成 HTTP 接口,请求体自动校验、回复包成 JSON 返回。
一、环境与前提
上一篇(第五篇)完成多轮对话的状态管理与记忆维护,本篇解决把agent-demo/里的Agent与run_agent暴露成 HTTP API 的问题。
调用约定沿用前五篇:端点内调用run_agent,传入一条用户输入字符串,拿到回复字符串,main.py一行不改。若你的run_agent需要显式接收Agent实例,在调用处多传一个参数即可,写法见第四节末尾。
前置条件(环境配置与_truncate_messages的截断策略见前几篇,此处不重复展开):Python 3.11+、openai 1.x、tiktoken、python-dotenv已安装;agent-demo/目录下main.py提供Agent类(__init__、_count_tokens、_truncate_messages)、run_agent、image_to_base64、main;.env中已配置OPENAI_API_KEY。
本篇只新增api.py,不动main.py:命令行入口继续可用,服务出问题时用python main.py对照一次,就能区分是接口层的问题还是Agent本身的问题。
本篇新增两个依赖,具体版本以 pip 实际安装到的为准(未确认版本):
| 依赖 | 在本篇的作用 |
|---|---|
fastapi | 定义路由、用 Pydantic 校验请求体、用Depends做依赖注入 |
uvicorn | 运行 FastAPI 生成的 ASGI 应用 |
步骤 1:安装依赖并确认导入链完好
目的:装上 Web 框架与 ASGI 服务器,同时确认main.py的既有名称仍可导入,不被新增依赖影响。
操作:在agent-demo/目录下依次执行:
pipinstallfastapi uvicorn python-c"import fastapi, uvicorn; print('ok')"python-c"from main import Agent, run_agent, image_to_base64, main; print('ok')"预期输出:后两条命令各回显一行:
ok实际输出:未实测;两条命令成功时都只回显ok,缺包时抛ModuleNotFoundError,名称不齐时抛ImportError。
若导入main的那条卡住不返回,说明main.py顶层直接调用了main(),给它补上if __name__ == "__main__":守卫即可(未实测)。
二、关键步骤
一次请求的完整链路是:curl把 JSON 发到/chat→uvicorn把请求交给 FastAPI → FastAPI 用ChatRequest校验并构造请求对象 →chat函数调用run_agent→ 返回值被ChatResponse序列化成 JSON。下面按这条链路落地。
步骤 2:新建agent-demo/api.py
目的:用 Pydantic 定义请求与响应模型,暴露一个POST /chat端点去调用run_agent,main.py保持不动。
操作:新建文件agent-demo/api.py,写入以下完整内容:
fromfastapiimportFastAPIfrompydanticimportBaseModelfrommainimportrun_agentclassChatRequest(BaseModel):message:strclassChatResponse(BaseModel):reply:strapp=FastAPI()@app.post("/chat",response_model=ChatResponse)defchat(request:ChatRequest)->ChatResponse:reply=run_agent(request.message)returnChatResponse(reply=reply)四点说明:
- 路由函数
chat用同步def声明,run_agent同样是同步函数,FastAPI 会把它放进线程池执行,不会阻塞事件循环(依据:FastAPI 官方文档 Defining Asynchronous and Synchronous Functions)。 ChatRequest的message: str让 FastAPI 自动生成请求体校验,字段缺失或类型不符会直接返回校验错误,不用手写判断。response_model=ChatResponse决定响应体只含reply字段,同时把该结构写进 OpenAPI 文档,/docs里能看到请求与响应的字段。app = FastAPI()这一行的实例名必须是app,uvicorn api:app靠它定位应用对象,写成别的名字会启动失败(见第三节)。
这个文件不构造Agent实例、也不碰self.messages,api.py只做协议转换,状态问题留给第三节。
操作(校验导入):仍在agent-demo/目录下执行:
python-c"import api; print(type(api.app).__name__)"预期输出:
FastAPI实际输出:未实测;成功时仅回显FastAPI,main.py导入失败时会先抛出它的原始异常。
步骤 3:启动服务
目的:把应用跑在本地端口上,供下一步调用。
操作:在agent-demo/目录下执行(--reload会在文件改动后自动重启,仅用于开发;按Ctrl+C停止):
uvicorn api:app--reload预期输出(启动行的具体格式随版本略有差异,未确认版本):
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) INFO: Application startup complete.实际输出:未实测;进程保持前台运行,控制台出现服务地址与Application startup complete.。
启动成功后,浏览器打开[/docs](/docs)能看到 FastAPI 自动生成的交互文档,/chat端点应出现在列表里。默认只绑定127.0.0.1,仅本机可访问;要让局域网内其他机器调用,启动命令需加--host 0.0.0.0(未实测,按 uvicorn 参数语义)。
步骤 4:发送一次正常请求
目的:确认端点真的把用户输入交给run_agent,并把回复包成 JSON 返回。
操作:另开一个终端(服务保持运行),执行:
curl-XPOST http://127.0.0.1:8000/chat\-H"Content-Type: application/json"\-d'{"message": "用一句话介绍你自己"}'Content-Type: application/json不能省:FastAPI 按 JSON 解析请求体,缺这个请求头会直接返回校验错误。
预期输出:
{"reply":"<Agent 的回复文本>"}实际输出:未实测;响应体是含reply字段的 JSON 对象,reply的文本由模型生成,每次调用内容不同。
步骤 5:验证请求体校验
目的:确认字段写错时服务端直接拒绝,校验不用自己写。
操作:
curl-i-XPOST http://127.0.0.1:8000/chat\-H"Content-Type: application/json"\-d'{"msg": "字段名写错了"}'预期输出:状态行为HTTP/1.1 422 Unprocessable Entity,响应体形如:
{"detail":[{"loc":["body","message"],"msg":"Field required","type":"missing"}]}实际输出:未实测;返回校验失败响应,detail数组元素包含loc、msg、type字段(形态依据 FastAPI 官方文档)。
三、失败处理
失败 1:uvicorn 找不到应用对象
报错原文:
AttributeError: module 'api' has no attribute 'app'模块名写错时报错原文:
Error loading ASGI app. Could not import module "api".原因:uvicorn 模块名:应用对象名要求模块里确实存在该名称的 ASGI 应用。常见触发方式有两种:FastAPI 实例没有命名为app(例如写成application = FastAPI());或者在agent-demo/目录下写成uvicorn main:app,模块名与app所在的文件不一致。
修复:把实例名改回app,启动命令的模块名与文件名对齐。
操作:在agent-demo/目录下执行:
uvicorn api:app--reload预期输出:与步骤 3 相同的启动行。
实际输出:未实测;修复后启动行与步骤 3 一致。
失败 2:共享Agent实例导致对话状态串扰
症状:并发请求时,一个请求写入的对话历史出现在另一个请求的上下文里;历史累积到超限时_truncate_messages(第五篇)会静默截断,用户侧表现为 Agent「忘记」早期对话。整个过程没有异常抛出。
原因:第五篇在Agent里用self.messages维护对话历史。若为了让 API「记住上下文」,把Agent实例化成模块级变量并让所有请求共用,历史就会跨请求累积。Depends本身每次请求都会调用依赖函数,但函数返回的是同一个模块级对象,注入的仍是同一实例(依据:FastAPI 官方文档 Dependencies)。
复现:新建文件agent-demo/api_shared.py,写入以下完整内容:
fromfastapiimportDepends,FastAPIfrompydanticimportBaseModelfrommainimportAgent,run_agent app=FastAPI()shared_agent=Agent()defget_agent()->Agent:returnshared_agentclassChatRequest(BaseModel):message:strclassChatResponse(BaseModel):reply:str@app.post("/chat",response_model=ChatResponse)defchat(request:ChatRequest,agent:Agent=Depends(get_agent))->ChatResponse:reply=run_agent(request.message)agent.messages.append({"role":"user","content":request.message})agent.messages.append({"role":"assistant","content":reply})returnChatResponse(reply=reply)@app.get("/history")defhistory(agent:Agent=Depends(get_agent))->dict:return{"messages":agent.messages}操作:用uvicorn api_shared:app --reload启动,另开终端连续发两次请求,再看历史接口:
curl-XPOST http://127.0.0.1:8000/chat\-H"Content-Type: application/json"\-d'{"message": "用户甲的第一条"}'curl-XPOST http://127.0.0.1:8000/chat\-H"Content-Type: application/json"\-d'{"message": "用户乙的第一条"}'curlhttp://127.0.0.1:8000/history预期输出:/history返回的messages里混着两次请求写入的全部消息,用户甲的内容出现在用户乙的会话记录中。
实际输出:未实测;串扰直接体现在/history的messages中(若生成回复时读取同一实例的self.messages,他人对话还会进入回复正文,此处机制为推测)。
修复:让每个请求拿到独立实例,只改get_agent这一个函数:
defget_agent()->Agent:returnAgent()操作:重启服务后再执行上一条curl [/history](/history)。
预期输出:messages只包含本次请求写入的消息。
实际输出:未实测;修复后/history只含本次请求写入的消息,不再出现其他请求的内容。
代价要说清:修复后跨请求不再有对话连续性;要维持会话,需要按会话标识隔离历史并引入额外存储,超出本篇范围。
失败 3:执行目录不对导致导入失败
报错原文(在agent-demo/之外执行python -c "import api"):
ModuleNotFoundError: No module named 'main'原因:api.py用from main import run_agent导入同目录模块,Python 只在当前工作目录与sys.path中查找main;执行位置不在agent-demo/时就找不到main,uvicorn 侧则表现为找不到api模块。
修复:先切到agent-demo/再执行任何命令。
操作:
cdagent-demo python-c"import api; print('ok')"预期输出:
ok实际输出:未实测;成功时仅回显ok。
四、替代方案与取舍
两个决定点各有两种做法。先看Agent实例的管理方式:
| 维度 | 方案 A:单例(模块级共享) | 方案 B:每请求新建(本篇默认) |
|---|---|---|
| 适用条件 | 本地开发、单用户调试 | 多用户服务、面向外部程序调用 |
| 代价 | 对话状态跨请求累积,不同用户互相污染 | 每次请求重新初始化Agent,tiktoken编码等固定开销重复支付 |
| 边界 | 并发一超过单用户就出现串扰,不能用于多用户 | 没有跨请求对话连续性;要维持会话需额外存储与会话管理,超出本篇范围 |
单例只有在一个人调试时才成立,一旦有两个调用方,它的边界就到了。每请求新建把状态彻底隔离,代价是放弃跨请求记忆,这也是第二节api.py不持有任何Agent实例的原因。
再看路由处理函数的声明方式:
| 维度 | 方案 A:同步def(本篇默认) | 方案 B:异步async def+asyncio.to_thread |
|---|---|---|
| 适用条件 | run_agent是同步函数,直接调用最省事 | 需要更高并发,愿意多写一层异步包装 |
| 代价 | 并发能力受 FastAPI 默认线程池大小限制 | 多一次线程调度,代码与调试都更复杂 |
| 边界 | 低流量、内部工具够用 | 在async def里直接调用同步run_agent会阻塞事件循环,延迟反而更高 |
方案 B 的完整实现如下(独立文件agent-demo/api_async.py):
importasynciofromfastapiimportFastAPIfrompydanticimportBaseModelfrommainimportrun_agentclassChatRequest(BaseModel):message:strclassChatResponse(BaseModel):reply:strapp=FastAPI()@app.post("/chat",response_model=ChatResponse)asyncdefchat(request:ChatRequest)->ChatResponse:reply=awaitasyncio.to_thread(run_agent,request.message)returnChatResponse(reply=reply)操作:在agent-demo/目录下用uvicorn api_async:app --reload启动,再执行步骤 4 的那条curl。
预期输出:与步骤 4 相同的 JSON 结构。
实际输出:未实测;响应体结构与步骤 4 一致。
若你的run_agent需要显式接收Agent实例:把Depends(get_agent)(每请求新建)加进路由参数,同步版写成run_agent(agent, request.message),异步版写成await asyncio.to_thread(run_agent, agent, request.message)。
以下情况不该用本篇方案:
- 需要跨请求的多轮记忆:
/chat每次请求都是新实例,别用它做必须记住上文的产品,先补会话存储再谈接口。 - 直接暴露公网:本篇没有鉴权与限流,不应作为公网服务运行。
- 生产部署:
--reload是开发期的自动重载,多进程部署与运维不在本篇范围。
下一步要解决的问题是:在这个 API 服务上增加流式输出,让用户逐步看到 Agent 的推理与回复过程。
参考资料
n8n-io/n8n
Significant-Gravitas/AutoGPT
huggingface/transformers