从 CLI 到浏览器:用 FastAPI、SSE 和 Vue 3 做一个可审核的 ReAct Agent
上一周,我用 Python 手写了一个 ReAct Agent。
它可以调用工具、流式输出回答、等待人工确认,还能把对话保存到 SQLite。站在终端里看,这个 Agent 已经能完成一条比较完整的任务链路。
但当我想把它交给别人使用时,问题就来了:用户需要先准备 Python 环境,再打开终端,理解日志里的工具调用信息,最后在命令行输入 yes 或 no。
我希望用户能打开网页,提出一个任务,看到 Agent 正在进行哪一轮处理、调用了什么工具、参数是什么;当涉及敏感操作时,页面弹出审核框,用户决定是否继续。
于是,Week 11 的任务变得很明确:把 Week 10 的 CLI Agent 搬进浏览器,完成从模型调用到页面交互,再到 Docker 上线的整个过程。
这个项目现在叫 ReAct Studio,已经完成线上部署。
- 在线体验:qishuixian.com/messageAgen...
- 项目源码:ai-fullstack-journey / week11
- 运行与部署文档:Week 11 README
这篇文章记录实际实现中的设计取舍、关键代码和部署踩坑。代码片段用于解释具体机制,部分省略了上下文,完整可运行工程以上面的仓库为准。
一、这一周,真正需要改变什么?
从学习路线来看,前面几周分别解决了不同层次的问题:
| 阶段 | 关注点 | 对这一周的帮助 |
|---|---|---|
| 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 |
step、content |
新增推理轮次 |
token |
step、content |
放入文本显示队列 |
tool_call |
tool、args、call_id |
展示工具卡片 |
approval_required |
approval_id、call_id、tool、args |
打开审核弹窗 |
approval_result |
approval_id、approved、content |
更新审核结果 |
tool_result |
call_id、tool、content、status |
展示执行结果 |
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 后,服务端数据还在,但浏览器失去了列表索引;换一台设备也不会自动同步列表。另外,localhost、127.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/,关键是把路径走一遍
项目线上入口确定为:
同一域名下还有其他项目,因此不能让 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:latest 和 message-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,从一个乘法任务开始,观察一次完整的模型与工具协作。
项目链接