AI Agents in Action · 第八章

把智能体
从你的终端里搬出去

前七章的智能体都活在你的终端里——python xxx.py,跑完就没了, 全世界只有你一个人能用。这一章讲怎么让它变成别人能用的东西。 而一旦别人能用,一堆你之前完全不用操心的问题就全冒出来了: 并发、幂等、密钥往哪放、CORS、出了事怎么查

前七章
python xxx.py —— 在你的终端里,跑完就没
只有你
8.1.1 · HTML
纯前端网页,浏览器直连模型,没有后端
开这个网页的人
8.1.2 · FastAPI
包成 HTTP 接口,POST /generate
能连上这台机器的人
8.2 · Docker
装进容器,扔哪都能跑
任何地方,任何人

每上一级,能碰到它的人就多一圈——要操心的事也多一圈

动手之前 —— 先说件扫兴的事

这一章的主例子,你大概率跑不了

本章从头到尾围绕一个图像生成智能体展开。而它需要的是 OpenAI 官方的图像生成接口ImageGenerationTool + gpt-image-1)——兼容 OpenAI 格式的第三方服务几乎都没有这个能力。 它们通常只实现 /v1/chat/completions,图像生成是另一套完全不同的接口。

但这一章依然值得学,而且可能是最值得学的一章。因为它真正教的东西 和图像生成毫无关系:怎么把智能体包成 HTTP 接口、怎么装进容器、 怎么处理重复请求、密钥往哪放。ImageGenerationTool 换成你前七章任意一个智能体,这一章的所有结论原样成立。

所以下面我会把重点放在骨架上,图像生成只当占位符看。

这一章有自己的 requirements.txt

chapter_08/requirements.txt 和仓库根目录那个是两套, 只有 6 行(fastapi / uvicorn / openai-agents / openai / dotenv / pydantic)。 它是给 Docker 镜像用的——容器里不需要 chromadb、phoenix 这些大家伙。 这本身就是个部署知识点:镜像里只装真正跑得着的东西。

第一级

嵌进网页:没有后端的智能体

最激进的一种部署:根本不部署。一个 HTML 文件,浏览器直接连模型。

三个 HTML,双击就能开

对应清单 8.1 / 8.3

chapter_08/ 下有三个 HTML,没有构建、没有 npm、双击即开。 它们通过 CDN 直接 import SDK:

import { RealtimeAgent, RealtimeSession }
  from 'https://cdn.jsdelivr.net/npm/@openai/agents-realtime@0.0.17/+esm';

三个文件的分工:

  • 01_embedded_agent_speech.html——纯语音,WebRTC 直连
  • 01_embedded_agent_speech_mcp.html——多两个输入框, 能连远程 MCP server(Streamable HTTP)
  • 03_realtime_image_agent.html——唯一和后端联动的, 有个 imgApiBase 输入框,默认 http://localhost:8000, 去调下一节那个 FastAPI
你得手动粘贴一个临时密钥

页面上有个 <input id="ephemeral" type="password">, 要你手工粘贴一个 ephemeral client key(页面里附了生成它的 curl 命令)。

这个设计恰恰是本章最重要的安全课:浏览器里绝对不能放你的真 API key—— 任何人按 F12 就能看见,然后拿你的额度随便烧。所以要用一个服务端签发的、 几分钟就过期的临时密钥

但注意这带来一个「先有鸡还是先有蛋」:签发临时密钥本身需要一个后端。 所以「纯前端无后端」只在你手动贴 key 的演示场景成立, 真上线你还是得有个服务端——这就自然过渡到下一节。

书里管这叫「氛围编码」

清单 8.1 和 8.3 的标题里都写着「vibe coded」(氛围编码)—— 作者在坦白这几个 HTML 是让 AI 一把生成的,不是精心手写的。 所以别把它们当前端最佳实践看,它们是能跑的演示,不是范本

第二级

包成 API:一个 POST,返回一张图

02_app.py 是本章的核心,101 行,只有一个路由

FastAPI:把智能体变成 HTTP 接口

对应清单 8.2

import base64
import os

from agents import (Agent, ImageGenerationTool, Runner,
                    set_default_openai_api, trace)
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response
from pydantic import BaseModel, Field

# 从 .env 读取 OPENAI_BASE_URL、OPENAI_API_KEY、OPENAI_DEFAULT_MODEL
load_dotenv()
set_default_openai_api("chat_completions")   # 仓库原文没有,接第三方模型用

# ----- 写死的配置 -----
# 书里写死的是 "gpt-5-mini",这里换成环境变量
CONTROLLER_MODEL = os.environ["OPENAI_DEFAULT_MODEL"]   # 跑这个智能体的大模型
TOOL_CONFIG = {
    "type": "image_generation",
    "quality": "high",
    "model": "gpt-image-1",       # ← 这个第三方服务基本没有,见开头说明
    "size": "1536x1024",
}

STYLE_GUIDELINES = """
## 所有图像的风格规范:

- **一致性**:每张图都要保持同样的摄影质感,3D 渲染元素要无缝融入
- **配色**:统一使用蓝、紫、暖金和绿色,点缀少量明亮的强调色
- **光照**:专业摄影级布光,戏剧性但色调温暖
- **信息图**:半透明的全息投影效果,不要喧宾夺主
- **角色**:AI 机器人要可爱、亲切,彼此有区别但又像一家人
- **图标**:用通用易懂的符号(灯泡代表想法、齿轮代表处理、爱心代表对齐等)
- **氛围**:乐观、有教育感、略带未来感,但不能冷漠或有压迫感
"""

# ----- FastAPI -----
app = FastAPI(title="Fixed-Config Image Generator API", version="1.0.0")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],        # 演示用,生产要收紧成具体域名
    allow_credentials=False,    # 用 "*" 时必须保持 False
    allow_methods=["POST", "OPTIONS"],
    allow_headers=["Content-Type"],   # 浏览器预检会问这个
    max_age=600,                # 预检结果缓存 10 分钟
)


class GenerateIn(BaseModel):
    # 调用方唯一能改的东西
    input: str = Field(
        ..., description="大白话的需求(提示词由智能体自己组织)。"
    )


def build_agent() -> Agent:
    """
    每个请求都新建一个智能体,避免请求之间串状态。
    工具配置在这里写死,不允许用户覆盖。
    """
    return Agent(
        name="Image generator",
        instructions=STYLE_GUIDELINES,
        model=CONTROLLER_MODEL,
        tools=[ImageGenerationTool(tool_config=TOOL_CONFIG)],
    )


def extract_image_b64(result) -> str | None:
    """
    从运行结果里翻出图像生成工具产出的 base64 数据。
    """
    for item in getattr(result, "new_items", []) or []:
        if (
            getattr(item, "type", None) == "tool_call_item"
            and getattr(item, "raw_item", None) is not None
            and getattr(item.raw_item, "type", None) == "image_generation_call"
            and getattr(item.raw_item, "result", None)
        ):
            return item.raw_item.result
    return None


@app.post("/generate", response_class=Response)
async def generate(body: GenerateIn):
    # 唯一的变量就是输入文本,其他全在代码里固定死
    agent = build_agent()
    print(f"正在为输入生成图像:{body.input}")
    with trace("Image generation"):
        result = await Runner.run(agent, body.input)

    b64 = extract_image_b64(result)
    if not b64:
        raise HTTPException(
            status_code=500, detail="图像生成工具没有产出任何结果。"
        )

    print("生成成功,返回 PNG 字节。")
    png_bytes = base64.b64decode(b64)
    # 直接返回 PNG 字节。没有文件名,没有 JSON —— 就是一张图。
    return Response(content=png_bytes, media_type="image/png")
这 101 行里藏着三个真正的部署课

一、build_agent() 每次请求新建一个智能体。 注释写得很直白:避免请求之间串状态。前七章你都是全局建一个 agent 然后一直用——那是因为只有你一个人在用。一旦并发, 共享的 agent 对象就可能把 A 用户的上下文泄给 B 用户。 这是从「自己跑」到「别人用」最容易踩的坑。

二、调用方只能改 input 一个字段。 模型、图片尺寸、质量、风格规范全写死在代码里。 为什么不做成参数?因为凡是用户能改的,用户就能拿来烧你的钱—— 把 quality 调到最高、尺寸拉到最大,账单是你的。 「固定配置」是个安全决策,不是偷懒。

三、返回的是裸 PNG 字节,不是 JSON。 Response(content=png_bytes, media_type="image/png")—— 所以你不能用 response.json() 去解析它, curl 得加 --output image.png这让前端能直接把它塞进 <img src>,省掉一次 base64 编解码。

CORS 全开是演示,不是范本

allow_origins=["*"] 意思是任何网站都能调你这个接口。 演示可以(因为要让本地 HTML 文件连上),但别抄进生产—— 那等于让全互联网拿你的 key 生成图片。注释里其实写了正确做法: 改成具体域名。

怎么跑起来

模块名以数字开头,得用字符串形式导入:

cd chapter_08
uv run uvicorn 02_app:app --host 0.0.0.0 --port 8000 --reload

# 试一下(注意 --output,因为返回的是裸 PNG 不是 JSON)
curl -X POST "http://localhost:8000/generate" \
  -H "Content-Type: application/json" \
  -d '{"input":"一个教小朋友太阳能的友好机器人"}' \
  --output image.png

注意 8000 端口——第三章的 SSE 示例也用它,别同时开。 另外 03_realtime_image_agent.html 默认就连 http://localhost:8000先起这个服务,再开那个网页

第三级

装进容器

Dockerfile 只有 27 行。但配套的 README 是坏的, 照着敲一定失败——这是本章最大的坑。

Dockerfile:27 行,没什么花样

对应清单 8.4

# 用官方的轻量 Python 镜像
FROM python:3.11-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PORT=8000

WORKDIR /app

# 装一些常用的系统依赖
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential curl ca-certificates && \
    rm -rf /var/lib/apt/lists/*

# 有 requirements.txt 就按它装,没有就装最小运行时依赖
COPY requirements.txt /app/requirements.txt
RUN if [ -f /app/requirements.txt ]; then \
    pip install --no-cache-dir -r /app/requirements.txt; \
    else \
    pip install --no-cache-dir "uvicorn[standard]" fastapi; \
    fi

# 拷贝项目文件
COPY . /app

# 暴露端口并启动 uvicorn,模块是 02_app:app
EXPOSE ${PORT}
CMD ["sh", "-c", "uvicorn 02_app:app --host 0.0.0.0 --port ${PORT}"]

PYTHONUNBUFFERED=1 值得一提:不设它,你的 print 会被缓冲住,docker logs 里半天看不到东西。 容器里这几乎是标配。

README_DOCKER.md 是损坏的 —— 里面有两份互相矛盾的副本

这个文件第 45 行有一行 AI 代码生成器的残留标记:

```// filepath: c:\Book_Code\AI-Agent-Workflows\README_DOCKER.md

从这行往后整篇文档又重新来了一遍。而两份副本说法不一致:

副本 A:app 在 02_app.py            模块 02_app:app
副本 B:app 在 chapter_08/02_app.py  模块 chapter_08.02_app:app

副本 B 还有个手滑:docker run --rm -p 8000:8000e OPENAI_API_KEY="…"—— -e 粘到端口上了,照抄必然报错。

而且两份副本给的 build 命令都是错的 —— 我实测了

两份都说:docker build -t image-generator:latest ., 并且强调「从项目根目录构建」。我照做了,结果是:

$ docker build -t bk-test .   (在仓库根目录)
ERROR: failed to read dockerfile: open Dockerfile: no such file or directory

原因很简单:Dockerfile 在 chapter_08/ 里,不在仓库根目录。 而且它 COPY requirements.txt 拿的是 chapter_08/requirements.txt,所以构建上下文也必须是 chapter_08。正确命令是:

# -f 指定 Dockerfile,最后那个 chapter_08 是构建上下文
docker build -f chapter_08/Dockerfile -t image-generator:latest chapter_08

# 或者干脆先 cd 进去,就能用 README 里那条了
cd chapter_08 && docker build -t image-generator:latest .

# 运行(注意 -e 前面要有空格)
docker run --rm -p 8000:8000 \
  -e OPENAI_API_KEY="sk-你的key" \
  -e OPENAI_BASE_URL="https://你的服务商/v1" \
  -e OPENAI_DEFAULT_MODEL="你的模型名" \
  image-generator:latest

注意运行时是-e 传环境变量,不是把 .env 打进镜像—— 密钥永远不该进镜像层,镜像是会被推到仓库、被别人 pull 的。 这是本章少数几个真正的安全知识点之一。

书里有 docker-compose,仓库里没有

对应清单 8.5 · 8.2.2 整节

这个文件根本不存在

书里 8.2.2 整节讲「用 Docker Compose 编排智能体系统」,清单 8.5 就叫 docker-compose.yaml但我在整个仓库里搜过—— 没有任何 docker-compose 文件。

所以这一节你只能照着书上的文字自己敲,没有配套代码可以对照。 考虑到 chapter_08/ 里只有一个服务(那个 FastAPI), 真要 compose 起来,多半还得配上第七章的 Phoenix 容器—— 那才是「编排」有意义的场景:应用 + 可观测性一起起来。

别人一用就会遇到的

幂等:同样的请求来了两次

这是「只有你用」和「别人也用」之间最典型的差别之一。

用输入算个哈希当缓存键

对应清单 8.6 · 06_idempotent_key_example.py

import asyncio
import hashlib
import json

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI(title="Idempotent-by-Inputs Tool Proxy")


class ToolIn(BaseModel):
    name: str
    args: dict


CACHE: dict[str, dict] = {}  # 仅用于演示


def cache_key_from_inputs(name: str, args: dict) -> str:
    """
    直接用函数的输入算出一个确定性的键。
    把 JSON 规范化,保证字典键的顺序稳定。
    """
    canon = json.dumps(
        {"name": name, "args": args},
        sort_keys=True,               # ← 关键:键排序,否则同样的参数会算出不同的哈希
        separators=(",", ":"),
        ensure_ascii=False,
    )
    return hashlib.sha256(canon.encode("utf-8")).hexdigest()


@app.post("/tool")
async def call_tool(body: ToolIn):
    key = cache_key_from_inputs(body.name, body.args)

    if key in CACHE:
        return {"cached": True, "result": CACHE[key], "key": key}

    # 假装在执行工具(换成真实调用即可)
    await asyncio.sleep(1.0)
    result = {"ok": True, "tool": body.name, "echo": body.args}

    CACHE[key] = result
    return {"cached": False, "result": result, "key": key}
为什么智能体特别需要这个

模型调工具是会重复的。它可能因为没看懂结果而重试, 网络抖一下 SDK 也会重试。如果那个工具是「下单」「发邮件」「扣款」, 重复执行就是事故

sort_keys=True 这个细节值得注意:{"a":1,"b":2}{"b":2,"a":1} 是同一个请求,但不排序的话哈希就不一样, 缓存全部落空。规范化是幂等键的命门。

这个文件的头部注释写错了自己的名字

文件第一行注释是 # chapter_08/08_idempotent_tool_inputs.py, 但它实际叫 06_idempotent_key_example.py。 另外 CACHE进程内的字典——注释也老实写了「仅用于演示」: 一重启就全没了,多个实例之间也不共享。真上生产得用 Redis 之类。

07 是个空壳

对应清单 8.7

07_phoenix_metadata.py 只有 14 行,而且函数体是 pass

from agents import trace
from openinference.instrumentation import using_metadata, using_session, using_user
from phoenix.otel import register

register(project_name="Agents In Action")

with (
    using_session("s-123"),
    using_user("u-42"),
    using_metadata({"turn_id": "t-1", "intent": "generate_image"}),
):
    with trace("agent-turn"):
        pass  # ← 就是这样,什么都不做
        # ... 你的智能体代码写在这里 ...

它是个骨架,不是能跑的例子。但它想说的事很实在, 也正好接上第七章:生产环境里,光有 trace 不够,你得能按用户、按会话去筛。 线上出事时,你要能回答「昨天下午投诉的那个用户到底遇到了什么」—— using_user("u-42") 就是为这个存在的。

这里少了一行,加上才对

对比第七章的 09,这个文件没有 set_trace_processors([])。 用第三方模型的话,得自己补上——否则默认那个处理器还会往 api.openai.com 发,然后一堆 401。

8.3 与 8.4

没有代码的那半章

8.3(高级部署策略)和 8.4(安全与治理)加起来十几个小节, 一行配套代码都没有——全是文字。但这半章的信息密度反而最高。

挑几条对用第三方模型的你特别相关的:

  • 成本控制与模型路由(8.3.8)。简单问题用便宜模型, 难的才上贵的。你用第三方服务的话,这件事比用官方更容易—— OPENAI_DEFAULT_MODEL 本来就是个环境变量,换起来零成本。
  • 可靠性:超时、降级、预算(8.3.7)。第四章那个 max_turns=25 就是最原始的一种预算。生产上还得管超时—— 模型卡住不返回时,你的 API 不能跟着卡死。
  • 密钥与配置管理(8.4.3)。前面已经说了:-e 传, 别打进镜像。这一条本章的 Dockerfile 做对了。
  • 提示注入与数据外泄防御(8.4.5)。这是整本书最该重视、 却只有文字没有代码的一节。你的智能体从第三章起就在读文件、 从第四章起就在往磁盘写东西——一旦用户输入能左右它调什么工具, 攻击面就打开了。第四章的护栏是起点,但远远不够。
  • 工具安全:沙箱与出站控制(8.4.4)。回想第三、四章: filesystem MCP server 的沙箱目录就是章节目录本身—— 那些例子真的有权往你的代码目录里写文件。演示无所谓, 生产上这个沙箱边界必须认真划。

跟着书走之前,先看这几条