AI 全栈学习之旅 -Week 11:从 CLI 到浏览器:用 FastAPI、SSE 和 Vue 3 做一个可审核的 ReAct Agent

从 CLI 到浏览器:用 FastAPI、SSE 和 Vue 3 做一个可审核的 ReAct Agent

上一周,我用 Python 手写了一个 ReAct Agent。

它可以调用工具、流式输出回答、等待人工确认,还能把对话保存到 SQLite。站在终端里看,这个 Agent 已经能完成一条比较完整的任务链路。

但当我想把它交给别人使用时,问题就来了:用户需要先准备 Python 环境,再打开终端,理解日志里的工具调用信息,最后在命令行输入 yesno

我希望用户能打开网页,提出一个任务,看到 Agent 正在进行哪一轮处理、调用了什么工具、参数是什么;当涉及敏感操作时,页面弹出审核框,用户决定是否继续。

于是,Week 11 的任务变得很明确:把 Week 10 的 CLI Agent 搬进浏览器,完成从模型调用到页面交互,再到 Docker 上线的整个过程。

这个项目现在叫 ReAct Studio,已经完成线上部署。

这篇文章记录实际实现中的设计取舍、关键代码和部署踩坑。代码片段用于解释具体机制,部分省略了上下文,完整可运行工程以上面的仓库为准。

一、这一周,真正需要改变什么?

从学习路线来看,前面几周分别解决了不同层次的问题:

阶段 关注点 对这一周的帮助
Week 7 FastAPI + Vue 的全栈项目结构 复用前后端分离与容器部署思路
Week 9 LangGraph 的图思维 理解状态、节点与执行路径
Week 10 手写 ReAct 循环 理解模型选择工具、执行工具、继续生成的底层过程
Week 11 Web 交互与工程化 把 Agent 的执行过程变成用户能操作的界面

我保留了 Week 10 的手写 react_agent,没有把它换成另一个高层 Agent API。这样,前一周写下的循环、消息结构和工具执行逻辑,都能在这一周继续发挥作用。

变化主要发生在输入输出边界:

text 复制代码
CLI 版本
input() → react_agent() → print() → input("是否批准")

Web 版本
浏览器任务 → HTTP/SSE → react_agent() → 结构化事件 → 页面
                                        ↑
                                  POST /approve

看起来只替换了几个函数,实际上需要重新考虑一组问题:

  • 一个回答尚未生成完,页面应该如何展示?
  • 工具调用、工具结果和普通文本,如何区分?
  • 用户点了"拒绝",后端怎样停止那次工具执行?
  • 浏览器断开以后,等待审核的任务怎么办?
  • 页面刷新时,恢复的是完整对话,还是一半的工具调用?
  • 本地流式输出正常,经过 Nginx 后还会不会逐步到达?

这些问题决定了 Web Agent 的使用体验。

这里也先明确"思考过程可视化"的含义:项目展示的是推理轮次、模型公开输出、工具调用参数、工具结果和审核状态。它不会读取或展示模型内部隐藏思维链。界面上出现"第 2 轮",意味着应用正在执行第二次模型调用,并不等于看到了模型全部内部推理。

二、先把架构拆清楚:Agent、服务和界面各管什么

技术栈没有刻意追求复杂:

层次 实际选择
Agent 内核 Python 手写 ReAct + LangChain 消息与工具接口
模型 DeepSeek,通过 langchain-openai 的兼容接口调用
服务 FastAPI + Uvicorn
流式通信 SSE + 浏览器原生 EventSource
前端 Vue 3 + TypeScript + Vite + Element Plus
数据 SQLite + 浏览器 localStorage
部署 Docker Compose + 容器 Nginx + 宿主机 HTTPS Nginx

项目没有同时引入 Tailwind CSS。按钮、弹窗等组件使用 Element Plus,页面布局和动画放在自己的 CSS 中。

核心目录如下:

text 复制代码
week11/
├── backend/
│   ├── agent.py                 # 异步 ReAct、模型初始化、工具
│   ├── main.py                  # HTTP/SSE、审核等待、运行协调
│   ├── store.py                 # SQLite 会话存储
│   ├── test_app.py              # 协议与 Agent 自动测试
│   ├── requirements.txt
│   ├── .env.example
│   ├── .dockerignore
│   └── Dockerfile
├── frontend/
│   ├── src/
│   │   ├── App.vue              # 会话、事件消费、打字机、审核
│   │   ├── main.ts
│   │   ├── env.d.ts
│   │   └── style.css
│   ├── vite.config.ts
│   ├── nginx.message-agent.conf
│   └── Dockerfile
├── docker-compose.yml
├── docker-compose.prod.yml
├── nginx.messageagent.conf
└── README.md

agent.py 不负责拼接 SSE 字符串,也不依赖 Vue。它只产生字典形式的事件。main.py 把这些事件送到 HTTP 连接,负责审核协调和完成后的保存。前端则根据事件类型更新界面。

这样拆分有一个直接收益:测试 Agent 时,可以传入一个假模型,完全不启动浏览器,也不消耗真实模型额度。

模型配置延续前面的项目,通过后端目录中的 .env 读取:

dotenv 复制代码
DEEPSEEK_API_KEY=填写自己的密钥
DEEPSEEK_BASE_URL=https://api.deepseek.com
MODEL_NAME=deepseek-chat
TOOL_TIMEOUT_SECONDS=3
APPROVAL_TIMEOUT_SECONDS=120

模型在 create_model() 中延迟初始化。没有密钥时,服务依然可以启动,测试也能运行;真正发起模型任务时才报告配置缺失。需要注意,/health 中的 model_configured: true 只表示读到了非空密钥,并不会替我们验证余额、权限和网络。

三、把 ReAct 循环改成异步生成器

1. 让执行过程本身成为输出

原来的 CLI 函数会直接打印内容,最后返回答案。Web 版本改为异步生成器,每遇到一个值得展示的状态,就 yield 一个事件。

下面是 agent.py 中模型调用部分的核心逻辑:

python 复制代码
for step in range(1, MAX_STEPS + 1):
    yield {
        'type': 'step',
        'step': step,
        'content': '模型正在生成回复或选择工具',
    }

    full_response = None

    async for chunk in model.astream(messages):
        full_response = (
            chunk if full_response is None
            else full_response + chunk
        )

        if isinstance(chunk.content, str) and chunk.content:
            yield {
                'type': 'token',
                'step': step,
                'content': chunk.content,
            }

    if full_response is None:
        raise ValueError('模型返回了空响应,请重试')

    if full_response.invalid_tool_calls:
        raise ValueError('模型返回了无效工具参数,请重试')

    calls = full_response.tool_calls
    messages.append(
        AIMessage(
            content=full_response.content or '',
            tool_calls=calls,
        )
    )

    if not calls:
        return

    # 后续:审核、执行工具、加入 ToolMessage,进入下一轮。

循环的基本含义仍然是 ReAct:模型提出动作,程序执行工具,把结果交还给模型,模型再决定是否需要继续。

这里设置了最多 5 轮模型调用。超过上限后,应用会给出缩小问题范围的提示,避免在工具与模型之间无限循环。

有个容易忽略的小细节:事件名叫 token,但它携带的是模型 SDK 返回的文本分片,不保证一个事件恰好对应一个模型 token,也不保证只包含一个汉字。后面的打字机效果,需要前端自己控制。

2. 流式工具调用,必须先合并再执行

普通回答分片可以直接展示,工具参数却不能边收到边执行。

例如,模型想调用:

json 复制代码
{
  "name": "calculator",
  "args": { "a": 12, "b": 8 }
}

底层参数可能分几次到达。在某个时刻,拿到的只是半段 JSON。

我沿用了 Week 10 Day 7 的消息分片合并方式:

python 复制代码
full_response = (
    chunk if full_response is None
    else full_response + chunk
)

等这一轮模型流结束,再从合并结果中读取 tool_calls。这样保留了工具调用 ID、名称和参数分片之间的关系,也避免自己手写一套容易出错的 JSON 拼接逻辑。

模型消息随后进入历史,工具执行结果则通过对应的调用 ID 关联回去:

python 复制代码
messages.append(
    ToolMessage(content=result, tool_call_id=call_id)
)

这个关联关系决定了下一轮模型能否正确理解"哪个工具调用得到了哪个结果"。

3. 用四个工具覆盖常见分支

本周保留了四个教学工具:

工具 实际行为 验证目标
get_weather(city) 返回北京、上海、广州的固定天气 普通工具调用
calculator(a, b) 计算两个整数的乘积 参数与结果传递
slow_tool() 异步等待 10 秒 超时与降级
send_email(to, subject, body) 返回模拟邮件结果,不真正发送 人工审核

天气不是实时查询,邮件也没有接入 SMTP。这样可以把注意力放在交互链路上,不需要先接入多个第三方服务。

工具执行使用异步超时控制:

python 复制代码
try:
    result = str(
        await asyncio.wait_for(
            tool_map[name].ainvoke(args),
            timeout=float(os.getenv('TOOL_TIMEOUT_SECONDS', '3')),
        )
    )
except asyncio.TimeoutError:
    result = '工具暂时不可用(执行超时),请稍后再试。'
    status = 'timeout'

超时会转换成工具结果,并作为 ToolMessage 交回模型,因此后续仍然可以解释失败原因。

这里也修正了 CLI 版本中的一个细节:在线程池上下文中对 future.result() 设置超时,并不意味着离开线程池时不会继续等待线程结束。本周的慢工具改成了 asyncio.sleep(),可以配合取消机制退出。

但这不是"所有工具都能强制中止"的保证。如果以后接入阻塞式 SDK、远端任务或者真实邮件服务,还需要对应的超时、取消与幂等方案。

四、先定义事件协议,再连接 Vue

如果后端只不断发送字符串,前端只能把内容堆在同一个气泡里。想增加工具卡片、审核弹窗和步骤编号,前后端必须先对事件达成一致。

本项目使用以下事件:

type 主要字段 前端处理
step stepcontent 新增推理轮次
token stepcontent 放入文本显示队列
tool_call toolargscall_id 展示工具卡片
approval_required approval_idcall_idtoolargs 打开审核弹窗
approval_result approval_idapprovedcontent 更新审核结果
tool_result call_idtoolcontentstatus 展示执行结果
done 终止标记 关闭网络流,等待打字机显示完
error content 显示错误并结束连接

例如一次工具调用会产生这样的事件:

json 复制代码
{
  "type": "tool_call",
  "step": 1,
  "tool": "calculator",
  "args": { "a": 12, "b": 8 },
  "call_id": "call-1"
}

注意,tool_call 表示应用准备处理一次调用,并不表示已经成功执行。前端要等 tool_result 才能展示结果。

1. FastAPI 输出符合 SSE 格式的数据

SSE 的编码函数很短:

python 复制代码
def encode(event):
    return f'data: {json.dumps(event, ensure_ascii=False)}\n\n'

每条事件后面的两个换行很关键,它们标志着当前消息结束。发送的是普通 SSE message,业务类型放在 JSON 的 type 字段中,因此前端使用 onmessage 统一分发即可。

FastAPI 返回:

python 复制代码
return StreamingResponse(
    stream(),
    media_type='text/event-stream',
    headers={
        'Cache-Control': 'no-cache',
        'X-Accel-Buffering': 'no',
    },
)

2. 为什么中间还需要一个 Queue?

最简单的实现,是在 HTTP 生成器中直接 async for 消费 Agent。

但模型可能在等待,人工审核默认更是可以等待 120 秒。如果生成器只在 Agent 产出事件时继续运行,这段时间就没有机会主动发送心跳。

项目把 Agent 放进独立异步任务,再通过 asyncio.Queue 把事件交给 SSE 输出循环。网络侧每 15 秒检查一次:有事件就发送,暂时没有事件就发送 SSE 注释心跳。

python 复制代码
task = asyncio.create_task(produce())

try:
    yield ': connected\n\n'

    while True:
        try:
            event = await asyncio.wait_for(queue.get(), 15)
        except asyncio.TimeoutError:
            yield ': heartbeat\n\n'
            continue

        yield encode(event)

        if event['type'] in ('done', 'error'):
            break
finally:
    task.cancel()
    await asyncio.gather(task, return_exceptions=True)
    active.discard(session_id)

心跳以冒号开头,浏览器 EventSource 不会把它当成普通业务消息交给 onmessage

这一层拆分让"Agent 什么时候产生业务结果"和"HTTP 连接什么时候需要发送数据"成为两个可以独立管理的问题。

当前 Queue 没有设置容量,适合这一版的学习规模。如果以后处理更大的输出量,还需要补充队列上限、慢客户端处理和背压策略。

3. 为什么选择 EventSource?

本项目的主要数据方向是服务端向浏览器推送:模型回复、工具事件、审核请求。用户批准或拒绝时,额外发一次普通 HTTP POST 就够了。

因此选择 SSE,可以直接使用浏览器原生能力,不需要管理 WebSocket 双向消息协议。

前端的连接代码如下:

ts 复制代码
const apiBase = (
  import.meta.env.VITE_API_BASE_URL || "/"
).replace(/\/$/, "");

source = new EventSource(
  `${apiBase}/chat?${new URLSearchParams({
    session_id: selected.value,
    request_id: turn.id,
    message: text,
  })}`,
);

原生 EventSource 发起的是 GET 请求,本项目把输入放在查询参数中,并限制为最多 2000 字符。这也带来了后续要改进的地方:生产场景可以先通过 POST 创建任务,再让 EventSource 使用随机任务 ID 订阅结果,避免把完整问题放进 URL。

五、人工审核:让后端真正等待一次决定

审核弹窗看起来是个前端组件,但是否允许执行,必须由后端控制。

完整过程如下:

text 复制代码
模型选择 send_email
  → 后端生成 approval_id,保存 Future
  → SSE 推送工具参数和审核 ID
  → 浏览器展示 el-dialog
  → 用户批准或拒绝
  → POST /approve
  → 后端验证会话与审核 ID,设置 Future 结果
  → Agent 执行工具或记录拒绝结果
  → 模型根据 ToolMessage 继续回复

1. 使用 Future 表达"尚未到来的结果"

main.py 用一个字典保存等待项:

python 复制代码
pending = {}

每次敏感调用都生成独立的审核 ID,并创建 Future:

python 复制代码
approval_id = str(uuid4())
future = asyncio.get_running_loop().create_future()
pending[approval_id] = (session_id, future)

向前端发送 approval_required 后,Agent 等待结果:

python 复制代码
try:
    approved = await asyncio.wait_for(
        future,
        float(os.getenv('APPROVAL_TIMEOUT_SECONDS', '120')),
    )
    reason = '用户已批准' if approved else '用户拒绝了该操作'
except asyncio.TimeoutError:
    approved, reason = False, '审核超时,已自动拒绝'

这里的等待会让出事件循环。等待一个用户决定时,服务仍然可以接收其他请求,SSE 心跳也可以继续发送。

完成、超时或取消时,等待项都会清理:

python 复制代码
finally:
    pending.pop(approval_id, None)
    if not future.done():
        future.cancel()

2. /approve 只提交决定,不重传可执行参数

浏览器提交的字段只有会话、审核 ID 和布尔决定:

python 复制代码
class Approval(BaseModel):
    session_id: str
    approval_id: str
    approved: StrictBool

后端在设置结果前校验等待项:

python 复制代码
item = pending.get(body.approval_id)

if not item or item[0] != body.session_id or item[1].done():
    raise HTTPException(409, '审核已结束或不属于此会话')

item[1].set_result(body.approved)

这样,重复提交、过期审核、已经取消的等待项和不匹配的会话都会被拒绝。工具名称与参数来自后端已经生成的调用,前端没有通过审核接口替换执行参数的入口。

前端则在用户点击后发送决定:

ts 复制代码
await api("/approve", {
  session_id: selected.value,
  approval_id: approval.value.approval_id,
  approved,
});

拒绝也必须形成一个明确的工具结果。否则模型只知道自己提出了工具调用,却不知道为什么没有获得结果。

在当前项目中,拒绝会写入 ToolMessage,模型随后可以告诉用户"没有执行"。审核是否通过由程序判断,不依赖模型在文字中承诺遵守。

这里还存在一个明确的工程边界:pending 和运行锁都在进程内,因此后端必须保持单进程。未来使用多个 worker 时,需要把审核状态和任务协调迁移到共享存储,不能直接把 --workers 改大就认为扩容完成。

六、打字机、工具卡片和历史恢复如何配合

1. 网络结束,不代表文字已经显示结束

模型返回文本的速度并不均匀。有时一次只有几个字符,有时一个分片比较长。

我把网络接收和视觉显示分开:收到文本后,先拆成字符放进缓冲队列,再用定时器控制显示速度。

ts 复制代码
let buffer: { event: AgentEvent; chars: string[] }[] = [];
let completed = false;

const timer = window.setInterval(() => {
  const item = buffer[0];
  if (item) {
    item.event.content =
      (item.event.content || "") + item.chars.splice(0, 3).join("");

    if (!item.chars.length) buffer.shift();
    scroll();
  }

  if (completed && !buffer.length) busy.value = false;
}, 20);

当前策略是每 20 毫秒最多显示 3 个字符。入队时使用 Array.from(),按 Unicode 码点拆分;它比直接按 UTF-16 下标切割更合适,但还不是完整的字素簇分割,复杂组合 Emoji 如果要精细处理,可以进一步使用 Intl.Segmenter

收到 done 后关闭 EventSource,同时标记网络侧已经完成;只有缓冲队列也显示完毕,才把界面切回空闲状态。否则就会出现服务端已结束,最后一截回答却还没显示完的问题。

2. 历史记录不能直接把每个分片渲染成段落

这一点是在浏览器联调时发现的。

实时接收过程中,同一轮连续的文本会不断合并到当前回答对象。数据库保存的却是原始事件:假设模型先返回"测试",再返回"完成",历史里就有两条文本事件。

如果刷新后直接按事件逐条渲染,原本连续的回答会变成多个独立段落。

恢复时需要把同一轮连续的 token 合并:

ts 复制代码
const previous = events.at(-1);

if (
  event.type === "token" &&
  previous?.type === "token" &&
  previous.step === event.step
) {
  previous.content =
    (previous.content || "") + (event.content || "");
} else {
  events.push({ ...event });
}

只合并连续文本很重要。如果中间已经出现工具调用或新一轮处理,就应该保留这个边界。

这个问题让我重新区分了两件事:数据库保存的事件序列,适合保留过程;用户看到的段落结构,则需要根据这些事件重新组织。

3. 界面优先回答"现在发生了什么"

本周的 UI 采用会话侧边栏加主对话区:用户消息靠右,Agent 回复与执行轨迹靠左,工具参数放在卡片中,审核用 Element Plus 的 el-dialog 展示。

消息气泡通过 CSS 做淡入动画,轮次用编号和线条区分,工具结果用状态样式提示成功、拒绝或超时。

这些设计的目的,是让用户在等待过程中知道当前状态:正在生成、准备调用工具、等待审核,还是已经降级返回。

当前回答使用 Vue 文本插值和 white-space: pre-wrap 显示,没有引入 Markdown 渲染器。因此模型返回的 Markdown 标记会以文本形式出现;富文本展示可以作为后续独立功能增加。

Element Plus 后来改为按需引入按钮、弹窗和消息样式。构建时的主 JS 从约 1.03 MB 降到约 168 KB,CSS 从约 367 KB 降到约 43 KB。这是本次构建的未压缩产物体积对比,不代表经过严格测试的首屏性能指标,但它说明:一个只用少数组件的页面,没有必要加载整个组件库。

七、会话与断线:比"把消息存起来"多走一步

1. 浏览器保留索引,后端保留内容

当前版本的会话 ID 由后端创建。浏览器 localStorage 保存会话列表与当前选中的 ID,SQLite 保存真正的消息和执行轨迹。

数据库中的 sessions 表主要包含四个字段:

sql 复制代码
CREATE TABLE IF NOT EXISTS sessions (
    id TEXT PRIMARY KEY,
    title TEXT NOT NULL,
    messages TEXT NOT NULL,
    turns TEXT NOT NULL
);

messages 保存给模型使用的消息,包含 AIMessage 的工具调用信息和 ToolMessage 的调用 ID;turns 保存给页面使用的用户输入与事件列表。两者都序列化为 JSON。

这样可以同时满足"模型记住前文"和"用户恢复执行轨迹"这两个目标。

不过,当前没有按用户查询全部会话的接口。清除 localStorage 后,服务端数据还在,但浏览器失去了列表索引;换一台设备也不会自动同步列表。另外,localhost127.0.0.1、不同端口属于不同来源,各自拥有独立的 localStorage。

这些是当前版本的取舍,不是数据库丢失了数据。

2. 一次用户请求,完成后再原子保存

Agent 可能在同一次任务中执行多轮模型调用。我把"用户发起一次任务直到 Agent 完成"作为保存单位,避免把半轮消息直接写进长期历史。

举个例子:模型已经生成了一个工具调用,但网络这时断开,工具结果还没写入。如果只保存了 AIMessage,下一次读取历史时,就可能得到一个缺少对应 ToolMessage 的上下文。

现在的做法是:从 SQLite 读出历史,在本次请求的内存消息列表中继续处理;Agent 正常结束后,再一次性更新模型消息与页面轨迹。

python 复制代码
turns = data['turns'] + [{
    'id': request_id,
    'user': message,
    'events': events,
}]

store.save(
    session_id,
    message[:24] if not data['turns'] else data['title'],
    data['messages'],
    turns,
)

await queue.put({'type': 'done'})

store.save() 在同一个 SQLite 事务中更新这两份数据。异常或取消时,本轮未完成的内存消息不会进入后续模型上下文。

这里的"原子"只针对本地数据库提交,不包含外部工具副作用的回滚。如果以后把模拟邮件换成真实邮件,即使本轮对话没有保存,已经发出去的邮件也不会自动撤回。

因此,真实写操作还需要业务幂等键、独立的执行记录,以及必要的补偿流程。

3. EventSource 自动重连,对 Agent 需要额外小心

原生 EventSource 有自动重连能力。但本项目的 GET /chat 会启动 Agent 执行,如果浏览器在连接出错后直接重发请求,就可能再次调用工具。

所以前端收到 done、业务错误或连接错误时,都主动关闭 EventSource:

ts 复制代码
function closeStream() {
  source?.close();
  source = null;
  approval.value = null;
}

后端另外做了两层检查:

  • active 集合限制同一会话同时只有一个任务。
  • request_id 已经出现在完成记录中时,拒绝再次运行。
python 复制代码
if session_id in active:
    raise HTTPException(409, '此会话正在生成回复')

if any(t['id'] == request_id for t in data['turns']):
    raise HTTPException(409, '请求已完成,请读取会话历史')

这可以减少当前单进程应用中的重复执行,但还不是严格的 exactly-once 保证:未完成的请求不会记录为已完成,外部副作用也没有持久化的幂等事务。

当前断线策略是停止连接、取消本轮任务,用户重新载入会话后核对已保存结果,再发起新任务。没有实现 Last-Event-ID 续传,也没有宣称浏览器刷新后可以继续原来的等待任务。

八、本地运行:把"服务启动"和"模型可用"分别验证

本机开发使用后端 8083、前端 8003。Python 要求 3.10+,Node 建议使用 20.19+ 或受支持的更新版本;本次本机验证使用过 Node 24,Docker 构建使用 Node 22。

克隆仓库后,在仓库根目录打开第一个 PowerShell:

powershell 复制代码
cd week11/backend
python -m venv venv
.\venv\Scripts\python.exe -m pip install -r requirements.txt

if (!(Test-Path .env)) { Copy-Item .env.example .env }
# 编辑 .env,填写自己的模型配置

.\venv\Scripts\python.exe -m uvicorn main:app --host 127.0.0.1 --port 8083 --reload

第二个终端从仓库根目录运行:

powershell 复制代码
cd week11/frontend
npm ci
npm run dev

打开 http://localhost:8003,后端文档地址是 http://127.0.0.1:8083/docs

开发时,前端通过 Vite 把 /chat/approve/sessions/health 代理到后端。浏览器请求同源地址,代理再转发给 8083。FastAPI 同时配置了本地 8003 的 CORS 允许来源,支持需要直接请求后端的场景。

我会先验证 /health 能返回,再发送一个明确要求使用工具的任务:

请调用 calculator 计算 12 乘 8,然后简短回答。

这比只问"你好"更有价值。它同时验证模型连接、工具参数、工具执行、ToolMessage 回传和最终回答。

本周还保留了 FastAPI 单端口方式:前端 npm run build 会把产物输出到 backend/static,先构建再启动后端,就能通过 8083 打开页面。

Docker 上线采用另一种结构:静态页面交给 Nginx 容器,FastAPI 容器只提供 API。两种运行方式共用业务代码,但静态文件的提供者不同。

九、部署到 /messageAgent/,关键是把路径走一遍

项目线上入口确定为:

qishuixian.com/messageAgen...

同一域名下还有其他项目,因此不能让 Week 11 的 API 直接占用站点全局 /api/

最终请求路径如下:

text 复制代码
浏览器
  https://qishuixian.com/messageAgent/
    ↓
宿主机 HTTPS Nginx
  保留 /messageAgent/ 前缀,转发到 127.0.0.1:8003
    ↓
前端容器 Nginx:80
  /messageAgent/            → index.html
  /messageAgent/assets/    → JS、CSS
  /messageAgent/api/chat   → backend:8083/chat
    ↓
FastAPI 容器:8083
  ReAct、SSE、审核、SQLite

1. Vite 的资源路径与业务接口路径分别配置

只修改 Vite base 不够,它主要影响构建产物中的资源地址。代码里手写的 fetch('/sessions')new EventSource('/chat') 也需要适配。

Docker 构建时设置:

dockerfile 复制代码
ENV VITE_BASE_URL=/messageAgent/ VITE_API_BASE_URL=/messageAgent/api/

前端页面的首页链接使用 import.meta.env.BASE_URL,fetch 和 EventSource 使用 VITE_API_BASE_URL。普通开发环境不设置这两个变量时,继续走根路径。

这些 VITE_ 变量属于构建配置,最终会进入前端产物。修改它们需要重新构建镜像,也不能把模型密钥放进其中。

前端 Dockerfile 使用多阶段构建:

dockerfile 复制代码
FROM node:22-alpine AS builder
WORKDIR /app/frontend
COPY package*.json ./
RUN npm ci
COPY . .
ENV VITE_BASE_URL=/messageAgent/ VITE_API_BASE_URL=/messageAgent/api/
RUN npm run build

FROM nginx:stable-alpine
COPY --from=builder /app/backend/static/ /usr/share/nginx/html/messageAgent/
COPY nginx.message-agent.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

最终镜像里没有 Node 开发环境,只保留 Nginx 与静态文件。后端则使用 python:3.11-slim,只复制依赖清单和运行源码。

2. 容器 Nginx:保留页面前缀,移除 API 前缀

前端容器中的核心配置如下,其他请求头设置可查看仓库完整文件:

nginx 复制代码
server {
    listen 80;
    server_name _;
    absolute_redirect off;
    root /usr/share/nginx/html;

    location = / { return 302 /messageAgent/; }
    location = /messageAgent { return 301 /messageAgent/; }

    location ^~ /messageAgent/api/ {
        proxy_pass http://backend:8083/;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header Connection "";
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 300s;
        gzip off;
        access_log off;
    }

    location /messageAgent/assets/ { try_files $uri =404; }

    location /messageAgent/ {
        try_files $uri $uri/ /messageAgent/index.html;
        add_header Cache-Control "no-cache";
    }

    location / { return 404; }
}

注意 API 代理末尾的斜杠:

nginx 复制代码
proxy_pass http://backend:8083/;

它会用 / 替换匹配到的 /messageAgent/api/。因此浏览器请求 /messageAgent/api/chat,后端实际收到 /chat

静态资源路径没有使用 SPA 兜底,找不到 JS 就返回 404。否则请求一个不存在的 JS 文件,收到的却是 index.html,浏览器会报出更难直观看懂的解析错误。

3. 宿主机 Nginx:这里反而不能加尾斜杠

宿主机需要把完整前缀交给前端容器,所以使用:

nginx 复制代码
location = /messageAgent {
    return 301 /messageAgent/;
}

location ^~ /messageAgent/ {
    proxy_pass http://127.0.0.1:8003;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Connection "";
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 300s;
    gzip off;
    access_log off;
}

这里的 proxy_pass 没有 URI 部分,转发时保留 /messageAgent/。如果写成 http://127.0.0.1:8003/,匹配前缀就会被替换掉,前端容器收到的路径与配置不一致。

两层代理的行为可以记成一个对照表:

位置 要完成的工作 proxy_pass
宿主机 Nginx 保留应用前缀 http://127.0.0.1:8003
前端容器 Nginx 去掉 API 前缀 http://backend:8083/

这个问题不适合只背"要不要加斜杠",需要明确每一层希望上游收到什么路径。

配置要放入已有 qishuixian.com 的 HTTPS server 中,与其他 location 平级。可以直接粘贴,也可以使用仓库提供的片段:

nginx 复制代码
include /opt/message-agent/nginx.messageagent.conf;

两种方式选一种即可。片段只有 location,不能直接当成完整站点文件放到 http 顶层。

4. SSE 要经过整个代理链路验证

后端使用 text/event-stream,只能说明应用按流输出。代理如果缓冲响应,浏览器仍然可能等全部生成结束才一次收到。

本项目两层 Nginx 都关闭了缓冲、缓存与压缩,设置 300 秒读取超时,应用侧则每 15 秒发送心跳。

proxy_read_timeout 关注的是两次读取之间的等待,不是给整个任务设置一个 300 秒总时限。模型和工具本身仍然需要各自的超时控制。

健康检查也要分层执行:

bash 复制代码
# 前端容器是否能提供页面
curl -I http://127.0.0.1:8003/messageAgent/

# 容器内 API 代理是否可用
curl -f http://127.0.0.1:8003/messageAgent/api/health

# 检查通过才重载宿主机 Nginx
sudo nginx -t && sudo systemctl reload nginx

# 最后检查公网 HTTPS 路径
curl -f https://qishuixian.com/messageAgent/api/health

如果本机端口正常、域名不正常,应继续检查宿主机生效的站点文件,而不是反复修改 FastAPI 路由。

十、Docker 交付与这次真正遇到的两个坑

1. 本机构建,服务器加载

部署采用两个镜像:message-agent-backend:latestmessage-agent-frontend:latest

Compose 将前端端口映射为 127.0.0.1:8003:80,后端映射为 127.0.0.1:8083:8083。外部通过宿主机 HTTPS Nginx 访问,容器间则使用 Compose 网络中的 backend 服务名。

SQLite 的配置是:

yaml 复制代码
environment:
  AGENT_DB_PATH: /app/data/agent_memory.db
volumes:
  - ./data:/app/data

数据保存在宿主机部署目录下,重建容器不会丢失。后端健康检查通过后,Compose 才启动前端;配置还设置了依赖更新时重启前端,帮助 Nginx 重新解析后端容器地址。这部分要求 Compose 2.17+。

在本机 week11 目录构建并导出:

powershell 复制代码
docker compose build --builder default
docker save -o message-agent-images.tar message-agent-backend:latest message-agent-frontend:latest

上传前,将下面的 <SERVER_IP> 替换为自己的服务器地址。这些命令假设服务器允许使用 root 进行 SSH 登录;如果使用普通账号,改成实际账号,并先准备好有写权限的上传目录。

powershell 复制代码
ssh root@<SERVER_IP> "mkdir -p /opt/message-agent/backend"
scp message-agent-images.tar docker-compose.prod.yml nginx.messageagent.conf root@<SERVER_IP>:/opt/message-agent/
scp backend/.env.example root@<SERVER_IP>:/opt/message-agent/backend/.env.example

实际密钥在服务器配置,不放进镜像或 Git。服务器加载镜像后,使用 docker-compose.prod.yml 启动,不需要现场重新编译源码。

镜像平台也要和服务器匹配。默认构建使用当前 Docker 引擎的平台,如果本机和服务器分别是 ARM 与 x86_64,就需要用 Buildx 指定目标平台。

2. Docker Hub 的请求,为什么收到了 Facebook 的证书?

有一次执行普通的 docker compose build,日志先创建了 BuildKit 容器,然后在获取 nginx 和 python 镜像元数据时失败:

text 复制代码
failed to fetch anonymous token
Get "https://auth.docker.io/token?..."
tls: failed to verify certificate
x509: certificate is valid for *.facebook.com, ... not auth.docker.io

报错位置指向:

dockerfile 复制代码
FROM nginx:stable-alpine

但从日志阶段来看,应用还没有开始构建。真正失败的是 Docker Hub 的认证请求,而且返回证书的域名与目标域名不匹配。

这时继续修改 Vue 或 Python 代码不会解决问题。

本机检查构建器后,显式指定了 Docker 内置构建器:

powershell 复制代码
docker buildx ls
docker compose build --builder default

随后 nginx、node、python 的元数据获取成功,两个应用镜像都完成了构建,后续编译步骤复用了缓存。

这条命令解决了本次构建路径上的阻塞,但并不能证明原路径的 DNS 或代理问题已经修复。仅凭这段证书日志,也不能判断具体是哪一层网络配置导致连接异常。

如果其他环境仍然出现相同问题,需要继续检查 Docker Desktop 的代理、VPN、DNS 或 hosts,而不是关闭 TLS 校验。构建器名称也应以当地机器的 docker buildx ls 输出为准,default 不是一条适用于所有环境的万能修复命令。

3. 上传成功后,普通用户没有目录写权限

另一个问题发生在服务器初始化 .env 时。

目录位于 /opt/message-agent,当前用户是 ubuntu,执行复制命令报错:

text 复制代码
cp: cannot create regular file 'backend/.env': Permission denied

这说明当前用户对目标目录没有写权限。需要处理的是 Linux 文件权限,而不是修改 Dockerfile。

这次使用管理员权限完成配置,且仅在文件不存在时创建,避免覆盖已经填好的密钥:

bash 复制代码
cd /opt/message-agent

sudo sh -c 'test -f backend/.env || cp backend/.env.example backend/.env'
sudo nano backend/.env
sudo chmod 600 backend/.env

sudo docker load -i message-agent-images.tar
sudo docker compose -f docker-compose.prod.yml config --quiet
sudo docker compose -f docker-compose.prod.yml up -d
sudo docker compose -f docker-compose.prod.yml ps

这里 sudo docker compose 也确保 Compose 有权限读取 root 所有、权限为 600 的 .env。后续如果要交给普通部署账号管理,可以再有针对性地调整部署目录归属,而不是把整个目录设成所有人可写。

服务器启动完成,再按上一节接入 HTTPS Nginx,就可以通过 /messageAgent/ 访问。

最后我还在个人站点的 index.html 增加了 ReAct Studio 入口。这是另一个独立的静态文件,更新仓库文件后,还要同步到个人站点的静态目录;Agent 容器重建不会自动更新宿主机个人主页。

十一、怎么证明它能工作:测试要覆盖状态变化

这类应用如果只检查"接口返回了 200",很容易漏掉实际问题。

在 SSE 中,HTTP 连接可能已经建立,Agent 之后才执行失败,失败信息通过 error 事件返回。只看状态码,无法判断任务是否完成。

1. 假模型测试真实循环

自动测试使用一个实现了 astream() 的 FakeModel。它故意把工具 JSON 参数拆成两段,再让真实 react_agent 合并并执行。

这种方式替换的是模型服务,不是把整条业务链路都模拟掉。参数合并、工具执行、审核等待、SSE 编码和 SQLite 保存仍然运行项目里的代码。

在后端目录执行:

powershell 复制代码
.\venv\Scripts\python.exe -m unittest -v test_app

现有 6 个测试方法覆盖:

测试方向 关键断言
分片、存储与隔离 工具参数正确合并、结果为 96、收到 done、不同会话历史独立、已完成请求被拒绝
审核批准与拒绝 两个分支正确执行、同会话并发请求被拒绝、错误会话和重复审核返回 409
审核超时 自动拒绝并清理 pending
工具超时 slow_tool 返回 timeout 状态
运行失败 不保存未完成上下文,并释放会话运行状态
取消 等待审核期间取消,清理 Future 和等待项

审核超时测试会把等待时间缩短,不需要每次真的等待 120 秒。假模型测试不会调用 DeepSeek,也不会消耗模型额度。

2. 浏览器与 Docker 联调

自动测试以外,我又沿着实际访问路径验证了下面这些行为:

  • TypeScript 检查与 Vite 生产构建通过。
  • 两个 Docker 镜像实际构建完成。
  • Compose 与两份 Nginx 配置通过检查。
  • 容器入口能提供 /messageAgent/ 页面和带前缀的资源。
  • 缺失的静态资源返回 404。
  • 真实模型调用计算器,结果是 96,SSE 最后收到 done
  • 浏览器能展示审核弹窗,拒绝后把结果交回模型并继续完成回复。
  • 容器重建后,刷新浏览器仍可恢复会话和审核轨迹。

开发阶段还使用过测试模型验证浏览器批准流程;Docker 阶段验证了真实模型触发模拟邮件、浏览器拒绝后继续生成的链路。这里的"真实模型"只表示调用了模型服务,邮件工具始终是模拟工具,没有实际发信。

在一次临时容器检查中,SSE 的连接注释立即到达,说明这次测试的代理路径没有一直缓冲到任务结束。它是一项功能验证,不是并发性能测试,也不能推导出所有网络环境下的延迟指标。

完成这些检查后,项目已经部署到线上。后续每次升级,仍然需要按相同路径检查,而不能把一次成功当成永久保证。

十二、已经上线,但还有哪些边界?

这个项目完成了一个可操作、可观察、可部署的 Agent 学习闭环。接下来如果要发展成多人使用的产品,还有几件事需要继续做。

第一,账号与会话权限。 当前没有账号认证,session_id 用来定位和隔离数据,不代表用户拥有访问权限。下一步应补充身份认证、会话归属校验和限流,而不是只依靠随机 ID。

第二,持久化任务与审核。 运行锁和 Future 仍在单进程内,服务重启会中断任务。多实例部署需要共享状态、持久化任务记录和明确的恢复策略。

第三,真实工具的幂等。 目前完成请求去重和关闭自动重连,只能解决一部分重复运行问题。涉及付款、发信、写数据库等真实动作时,需要为工具调用单独设计业务幂等和执行审计。

第四,长会话与存储成本。 当前会话历史完整读取,展示事件和模型消息以 JSON 形式存储。随着会话变长,需要上下文裁剪或摘要、事件分页,并评估同步 SQLite 读写对事件循环的影响。

第五,任务与传输分离。 未来可以用 POST 创建任务,用 GET 订阅事件,加入事件 ID 和受控续传。当前的断线策略简单可解释,但还不能恢复执行中的会话。

第六,更完善的界面。 继续拆分 App.vue、提取 SSE composable、增加带清理机制的 Markdown 渲染、提供工具轨迹折叠,都可以在现有事件协议之上推进。

回头看这一周,我最大的收获是开始把 Agent 当作一个有生命周期的任务来设计。

模型会生成内容,工具会产生结果,用户会在中途做决定,网络可能断开,数据需要在合适的时机保存。把这些事情连起来,才形成了浏览器里那个看似简单的聊天窗口。

后续我会继续沿着任务持久化、权限控制和工具幂等这条路线完善它。现在也可以直接打开 ReAct Studio,从一个乘法任务开始,观察一次完整的模型与工具协作。


项目链接

相关推荐
爱勇宝44 分钟前
《道德经》第 11 章:真正有用的,常常是你没写出来的那部分
前端·后端·产品
jearry1 小时前
WebView2 原生拖放的桥接之道:拆解 yyzTools 的 drop-zone.js
前端·c++
2601_966949651 小时前
批量 K 线不只是提速:因子研究中的数据质量、复权与回测偏差
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
10share1 小时前
我为什么用 React 重写了一个 VitePress
前端·react.js
洛阳纸贵1 小时前
AI-PyTorch(一)基础代码实操和自动求导
人工智能·pytorch·python
计算机魔术师1 小时前
GPT-6 Astra 发布后,Sebastian Raschka 解析 looped transformer 与隐藏推理链传闻
前端
不可能片场1 小时前
Electron 退化成 node:一个环境变量的锅
前端·electron
2601_962284501 小时前
uv:终结 Python 虚拟环境管理乱局
python·项目管理·uv·虚拟环境·依赖管理
楚楚河河1 小时前
js 变量声明
前端