☰
AI智能体对话平台开发实战:从零搭建第一版可运行骨架
2026/10/19 11:29:19 网站建设 项目流程

AI智能体对话平台开发实战这个系列,终于写到第二篇了。上一篇我们把整个系统的需求边界、模块划分和演进路线图捋了一遍,当时很多朋友在评论区留言问“什么时候开始写代码”,现在可以明确地说,就是今天。这篇的核心任务很纯粹——地基搭建,从零开始写第一行代码。

说“从零”其实不太精确,更准确的说法是:在一个干净的目录里,从设计决策开始,逐行搭建一个AI智能体对话平台的最小可运行骨架。你别小看这一步,很多项目死在半路,不是因为模型不够聪明,而是地基没打好——数据结构一改再改,协议字段反复推翻,前后端联调时才发现通信格式对不上。这篇就是把那些坑先替你踩一遍,把地基一次打正。

这篇内容适合谁?两种人最受益:一是刚接过智能体对话项目、准备从零落地的开发者,你想知道第一行代码应该写在哪里、为什么写在那里;二是已经有了一些模块代码、但发现系统越来越拧巴的人,你会在数据结构与通信协议的章节里找到“拧巴”的根源。无论你是用商用大模型接口,还是准备部署本地模型,这套地基的设计思路都通用,因为真正的智能体平台,核心从来不是某个模型,而是围绕对话的整套工程骨架。

1. 项目整体设计与技术选型思路

1.1 为什么后端选Python栈而不是其他方案

做智能体对话平台,后端候选语言无非是Python、Node.js、Go这么几个主流选项。我在项目里最终选了Python,核心原因有三个。

第一个原因是AI生态的“主场优势”。你之后要接大模型接口、做提示词工程、上RAG检索、处理向量化,这些场景下Python的库是最全的,踩坑资料也最多。用别的语言不是不行,但每往前走一步,都可能遇到“这个库没维护了”“这个功能没有Python版”的尴尬,而Python基本是随要随有。

第二个原因是团队协作的隐性成本。对话平台的逻辑演进非常快,今天加一个意图识别,明天接一个工具调用,后天调整提示词模板。Python的动态特性和快速迭代能力,能让想法到代码的路径最短。这不代表Python没有缺点,后面我们也会用类型标注和数据类来补上动态语言的约束短板。

第三个原因是FastAPI这个框架本身。它原生支持异步、自动生成接口文档、对WebSocket的支持也是第一公民级别,这些特性几乎是为对话平台量身定做的。真实场景里,对话过程中会有大量的双向实时通信和服务端主动推送,FastAPI在这方面的体验非常顺。

Node.js的强项是I/O密集和高并发,但对话平台的核心瓶颈在模型调用链路的延时,而不是网络I/O本身;Go的优势是部署形态和并发性能,但AI生态的丰富程度和快速迭代能力都不如Python顺手。综合下来,Python + FastAPI 是当前阶段综合成本最低的选择。

技术选型没有绝对的对错,核心是匹配你当前的核心矛盾和团队结构。如果你的团队全是Go背景,硬上Python反而会拖慢进度,这时候选Go也合理。但如果你是从零起步、没有历史包袱,Python栈是投入产出比最高的路线。

1.2 通信层为什么选WebSocket而不是HTTP轮询

对话平台的通信层是一个容易被低估、后期很难改的设计决策。先用最简单的HTTP接口做一版,看起来很快,但实际跑起来你就知道了:消息不同步、状态要靠前端反复刷新、流式输出没法做,用户体验非常别扭。

我们的场景天然需要一条长连接通道,原因有两点。第一,服务端需要主动推送。大模型回复生成方式是流式的,一个完整的回答可能要几秒甚至几十秒,如果等到完全生成完再一次性返回,用户会对着屏幕干等。WebSocket可以让服务端把生成好的每一个片段即时推给前端,用户看到的是一个字一个字蹦出来的效果,感知延迟会大大降低。第二,会话状态需要实时同步。对话过程中会有“断开重连后恢复上下文”的需求,长连接能天然维护会话级的在线状态,HTTP轮询要做到这一点,复杂度会成倍上升。

通信方式服务端主动推送实时性连接开销适配流式输出
HTTP轮询不支持取决于轮询频率高,频繁建连差
SSE支持单向推送好低好
WebSocket双向实时最好低,单条长连接复用好

SSE(Server-Sent Events)在纯流式场景里表现不错,而且比WebSocket简单,但它有个致命限制:只支持服务端到客户端的单向通信,客户端要发消息还得另走HTTP。在对话场景里,前端发送消息后马上要接收流式响应,用SSE意味着要维护两条通道,状态同步的心智负担会更重,所以我直接选了WebSocket,一条链路解决双向通信。

还有一个设计细节需要提前考虑:WebSocket协议自带心跳机制,但在真实环境中,代理服务器或负载均衡器可能会掐断空闲连接。我们可以在应用层加一个轻量心跳,前端每隔几十秒发一条ping消息,服务端收到后回一条pong,这样连接就能长时间保持稳定。这个细节第一版就要加,不然后期线上偶发断连、还找不到原因的时候,排查起来会很痛苦。

1.3 目录结构规划:模块边界先从物理上划清

很多开发者在项目初期不重视目录结构,所有的代码堆在几个文件里,等代码量上来之后,模块之间的依赖关系纠缠不清,想拆都拆不动。这个项目在一开始就规划好物理边界,后面每个模块的演进都会有清晰的位置。

我规划的目录结构大致如下:

agent_platform/ ├── backend/ │ ├── app/ │ │ ├── main.py # 应用入口 │ │ ├── api/ │ │ │ └── ws.py # WebSocket接口层 │ │ ├── core/ │ │ │ └── config.py # 配置管理 │ │ ├── models/ │ │ │ ├── message.py # 消息数据模型 │ │ │ └── session.py # 会话数据模型 │ │ ├── agents/ │ │ │ ├── base.py # Agent基类 │ │ │ ├── registry.py # Agent注册与路由 │ │ │ └── mock_agent.py # 第一版演示Agent │ │ └── storage/ │ │ └── session_store.py # 会话存储 │ └── requirements.txt └── frontend/ ├── index.html └── app.js

这个目录结构遵循的是一个核心原则:接口层、数据模型层、业务逻辑层、存储层各自独立,每一层只能依赖下一层。websocket接口收到消息后,转给Agent层处理,Agent层读写存储层的数据,数据模型是各层之间传递的标准载体。这样分完之后,每一块的职责都清晰了,后面做测试、排查问题、替换实现,都是局部操作,不会牵一发动全身。

2. 核心数据结构与通信协议定义

2.1 消息模型的字段设计与取舍

做对话平台,最核心的数据结构就是“消息”。如果你把消息模型定义错了,后面所有功能都会在这个错误的基础上不断打补丁。

我第一版的消息模型定义如下:

# backend/app/models/message.py from pydantic import BaseModel, Field from typing import Optional, Dict, Any import time import uuid def new_msg_id() -> str: return str(uuid.uuid4()) def now() -> float: return time.time() class Message(BaseModel): msg_id: str = Field(default_factory=new_msg_id, description="消息唯一ID") session_id: str = Field(..., description="所属会话ID") role: str = Field(..., description="消息角色: user/assistant/system/tool") content: str = Field(..., description="消息内容,统一为字符串") msg_type: str = Field(default="text", description="消息类型: text/image/tool_call") create_time: float = Field(default_factory=now, description="消息生成时间") meta: Dict[str, Any] = Field(default_factory=dict, description="扩展字段")

这个设计有几个细节是在实际项目中磨出来的。

第一,content统一用字符串。有些设计会把结构化内容拆成JSON字段,比如tool调用参数、图片URL这些——当然,后续如果某个message需要携带一个结构化对象,我们把它JSON序列化后放进content里,在meta字段中标记它的类型即可。这个取舍看起来过于简化,但实际用下来会非常省心,因为所有消息的“内容”字段的读写逻辑都一致,渲染层也能统一处理。

第二,msg_type的存在是为了预留富消息能力。对话平台不可能永远只发纯文本,后面的图片消息、工具调用结果、卡片消息都需要类型标识区分。但第一版只实现text,其他的等用到时再扩展。

第三,meta是万能的“补丁袋”。真实的对话场景中,消息可能带token消耗、带推理耗时、带引用来源。这些信息不适合频繁改表结构,放进meta里最合适。我给它的定位是“暂存不确定需求的地方”,后续如果发现某个meta字段是高频互动逻辑必需的,再把它提升为独立字段。

有一个设计心法值得记住:数据模型先想清楚谁会写、谁会读、谁会改。消息的写入方是用户输入和Agent输出,读取方是前端渲染和上下文组装,修改方基本不存在——消息应该是只追加的。想清楚这一点,你就知道为什么消息ID要用UUID而不是自增ID,以及为什么session_id要冗余在每一消息里,而不是靠外层关联去查。

2.2 会话模型与Agent路由设计

消息模型解决的是“一条消息长什么样”的问题,会话模型解决的是“一串消息怎么组织”的问题。

# backend/app/models/session.py from pydantic import BaseModel, Field from typing import List, Optional import time class Session(BaseModel): session_id: str = Field(..., description="会话唯一ID") title: str = Field(default="新会话", description="会话标题") agent_id: str = Field(default="mock-agent-01", description="当前绑定的Agent") create_time: float = Field(default_factory=time.time) update_time: float = Field(default_factory=time.time) context_summary: Optional[str] = Field(default=None, description="上下文摘要")

session里我放了agent_id字段,这是整个智能体架构的关键设计。一个对话平台往往不只有一个智能体,而是有多个不同职责、不同人设的智能体协同工作。用户发一条消息过来,系统要先判断这条消息该由哪个Agent处理,再把消息路由过去。

第一版虽然只有一个MockAgent,但路由机制要从第一天就立好。这个机制很像一家公司的总机:用户的请求进来,先接总机,总机判断应该转给哪个部门,再把通话接到对应分机。分机之间能力不同,但对用户来说,转接过程应该是无感的。

会话与Agent的关系也值得想清楚:一个会话在创建时选定一个Agent,中间可以切换但很少见,大多数场景下会话的生命周期内Agent是固定的。把agent_id放进session而不是放进每一条message里,既保持了消息模型的简洁,又能让会话级别的Agent调度成为可能。

2.3 协议约定:字段、状态码与版本控制

前后端联调阶段最痛苦的事情不是写代码,而是两边对协议的理解不一致。前端说“我发的是message字段”,后端说“我收的是content字段”,这种低级错误几乎在每个项目里都会发生。所以通信协议必须在一开始就用文档的形式固定下来。

我约定的WebSocket消息格式如下,双向统一:

{ "type": "message", "msg_id": "uuid字符串", "session_id": "会话ID", "role": "user", "content": "消息内容", "create_time": 1732000000.123, "meta": {} }

type字段用来区分消息的类型:message是普通对话消息,ping/pong是心跳消息,error是错误消息,tool_call是后续的工具调用消息。第一版只需要前三种,但字段从第一天就留好。

关于字段命名,我坚持一个原则:全链路统一用snake_case。Python后端天然用snake_case,JavaScript前端习惯用camelCase,两边如果不做约束就会互相为难。我的做法是后端定义好协议后,前端用一个小函数做一次属性映射,或者干脆前端也用snake_case,虽然不符合JS惯例,但在一个小团队里,全链路命名统一带来的心智负担降低,远比代码风格上的“惯例正确”有价值。

版本控制方面,第一版先不引入复杂的协议版本号,只在心跳消息里带一个version: "v1"字段。这个字段在后续协议演进时能帮助你快速识别老旧客户端,定向做兼容处理。

提示:协议字段一旦定下来并开始流通,改名就是一次面向全部存量数据、全部存量前端代码的灾难性变更。在项目初期宁可多想几天,也不要匆忙定稿。我见过太多团队在联调阶段因为一个字段名反复改代码,最后拿胶带缠出来的系统,处处漏风。

3. 后端服务搭建:从初始化到第一个WebSocket端点

3.1 开发环境准备与项目初始化

先把开发环境准备好。这一步虽然基础,但把虚拟环境隔离好、把依赖锁定好,能避免很多日后“在我机器上能跑啊”的争论。

当前开发机的环境和工具链:

# 推荐使用 Python 3.10 或 3.11,我实测下来这两个版本对异步生态的兼容性最稳 python3.11 -m venv venv source venv/bin/activate # Windows上执行 venv\Scripts\activate pip install fastapi uvicorn[standard] pydantic

依赖就这三个,第一版刻意不做过度设计。没有数据库驱动、没有ORM、没有消息队列,因为你还在搭地基,先把最核心的服务骨架立起来,再往上加东西。

初始化完成后,写第一行真正意义上的业务代码——一个健康检查端点。别小看这个端点,后面部署、排查、健康检查全靠它:

# backend/app/main.py from fastapi import FastAPI app = FastAPI(title="Agent Chat Platform") @app.get("/health") async def health_check(): return {"status": "ok"}

启动方式:

uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

启动后浏览器访问http://localhost:8000/health,能看到{"status":"ok"}就说明服务活了。这一行代码虽然简单,但它验证了整条链路——虚拟环境没问题、依赖装好了、代码能加载、服务能启动。地基的第一块砖放稳了。

3.2 连接管理器:对话平台的“接线总机”

写第一个WebSocket端点之前,要先设计一个连接管理器。很多人会把连接管理直接写在端点函数里,两三个连接时看着没问题,一旦连接多起来,代码就乱成一团。更好的做法是把连接管理抽成一个独立的类,它专门负责维护“哪些session_id对应哪个WebSocket连接”,以及消息的定向发送和广播。

这个类的职责可以类比我刚才说的总机接线员:每个电话(连接)进来,接线员记录是哪条线(session_id),挂断时移除记录,发消息时找到正确的线播出去。它的代码并不复杂,但它是对话平台稳定运行的底座之一。

# backend/app/api/ws.py from fastapi import WebSocket from typing import Dict class ConnectionManager: def __init__(self): self.active_connections: Dict[str, WebSocket] = {} async def connect(self, websocket: WebSocket, session_id: str): await websocket.accept() self.active_connections[session_id] = websocket async def disconnect(self, session_id: str): if session_id in self.active_connections: del self.active_connections[session_id] async def send_to_session(self, session_id: str, message: dict): if session_id in self.active_connections: await self.active_connections[session_id].send_json(message)

这个管理器的设计有一个值得注意的细节:用session_id作为连接字典的key,而不是WebSocket对象自身。原因在于对话平台是按会话维度组织的,用户断开重连后,新的WebSocket对象可以和老会话绑定,消息发送方不需要关心底层连接是谁,只要知道会话ID就能把消息送到正确的地方。这个设计在后续做多端登录、会话迁移时会省非常多的力气。

一个真实的场景:用户在PC端开了一个会话,临时有事出门又用手机打开同一个会话。如果连接按session_id维护,手机端的连接会自动覆盖PC端的连接,消息自动发到手机端,用户无感切换。如果按WebSocket对象维护,这种场景的实现就麻烦得多。

3.3 核心接口实现:/ws端点的完整代码

连接管理器就位后,写第一个真正的WebSocket端点。这个端点会接收前端发来的消息,交给Agent路由层处理,再把处理结果通过连接管理器推送回前端。

# backend/app/api/ws.py import json import time from fastapi import APIRouter, WebSocket, WebSocketDisconnect from app.api.ws import ConnectionManager from app.agents.registry import agent_registry router = APIRouter() manager = ConnectionManager() @router.websocket("/ws/{session_id}") async def websocket_endpoint(websocket: WebSocket, session_id: str): await manager.connect(websocket, session_id) try: while True: raw_data = await websocket.receive_text() request = json.loads(raw_data) # 处理心跳消息,保持连接活跃 if request.get("type") == "ping": await manager.send_to_session(session_id, { "type": "pong", "time": time.time(), }) continue # 普通对话消息,交给Agent路由层处理 if request.get("type") == "message": user_message = request.get("content", "") if not user_message.strip(): continue response = await agent_registry.route(session_id, user_message) await manager.send_to_session(session_id, response) except WebSocketDisconnect: await manager.disconnect(session_id)

这个端的逻辑是:进入一个无限循环,持续接收前端消息。如果是ping就回pong,如果是对话消息就提取content,交给agent_registry.route()处理,然后把返回值直接send_json推给前端。

有一个细节值得留意:这里用的是receive_text而不是receive_json,接收后手动json.loads解析。原因是我在之前的项目里遇到过一个奇怪的问题——前端用某个版本的WebSocket库发送消息时,FastAPI的receive_json偶尔会解析失败,而手动解析的方式在所有客户端下都稳定。虽然多加几行代码,但在通信链路的稳定性上多了一重保障。

Agent路由层agent_registry.route()目前还不存在,下一步就实现它。

4. Agent核心逻辑:从“复读机”开始的可演进设计

4.1 为什么第一版先写一个MockAgent

对一部分开发者来说,看到“Agent核心逻辑”这几个字,第一反应是上去就接大模型API。但在地基搭建阶段,我会强烈建议你先写一个MockAgent——它不调用任何外部模型,只根据规则返回一条固定格式的回复。

这个建议的底层逻辑是:先把整条通信链路跑通,再接入真正的“大脑”。如果你一上来就接大模型API,出了问题很难定位——消息是前端没发出去?WebSocket断开了?还是模型接口挂了?还是提示词写错了?链路里变量太多,排查就会变成猜谜游戏。先用MockAgent把所有基础设施验证一遍,确保链路畅通后,再接模型,那时候出问题你就能自信地排除底层问题,把注意力集中在模型层。

成本上的考量更直接:大模型API都是有成本的,开发调试阶段频繁调用,费用累积起来不是小数目。用MockAgent做联调和功能测试,省下的钱足够给生产环境买好一点的模型额度。

MockAgent本质上是一个“复读机”,但它承担了极其重要的验证使命。只有当你能让一个复读机完整走一遍“用户消息进来、Agent处理、服务端推送回前端”的全流程,你才算真正拿到了进入下一关的入场券。

4.2 Agent基类与路由分发机制实现

Agent基类定义的是所有智能体的公共接口。未来的每一个Agent都要继承这个基类,实现process方法,声明自己的元信息。

# backend/app/agents/base.py from abc import ABC, abstractmethod class BaseAgent(ABC): agent_id: str = "" name: str = "" description: str = "" @abstractmethod async def process(self, session_id: str, user_message: str, context: list) -> dict: ...

路由注册机制设计成一个全局单例。每个Agent在创建时把自己注册进registry里,路由层根据session绑定的agent_id找到对应的Agent实例。

# backend/app/agents/registry.py class AgentRegistry: def __init__(self): self._agents = {} def register(self, agent: BaseAgent): self._agents[agent.agent_id] = agent def get(self, agent_id: str): return self._agents.get(agent_id) async def route(self, session_id: str, user_message: str) -> dict: # 第一版简化:所有会话都走默认Agent agent = self._agents.get("mock-agent-01") context = [] return await agent.process(session_id, user_message, context) agent_registry = AgentRegistry()

这里我要提醒一个设计上的取舍:第一版直接从session里读agent_id再路由其实不难,但为什么我不做?因为第一版整个平台只有一个Agent,过早地把多Agent路由逻辑堆上去,等于在还没有需求的时候写了一套用不上的代码。地基的原则是“够用且正确”,而不是“为未来一次性铺满”。多Agent路由的逻辑,等到第二个Agent真的出现时再迭代也不迟,届时只需要把route函数里的硬编码改为从session读取agent_id即可,改动面非常小。

4.3 MockAgent的实现与后续扩展点设计

MockAgent的实现很简单:

# backend/app/agents/mock_agent.py import time from app.agents.base import BaseAgent class MockAgent(BaseAgent): agent_id = "mock-agent-01" name = "复读机Agent" description = "第一版演示Agent,返回规则化回复" async def process(self, session_id: str, user_message: str, context: list) -> dict: return { "type": "message", "msg_id": f"mock_{time.time_ns()}", "session_id": session_id, "role": "assistant", "content": f"收到消息:{user_message}。当前是地基版MockAgent,接入模型后我会变聪明。", "create_time": time.time(), "meta": {"agent_id": self.agent_id}, }

这段代码虽然只有十几行,但它确定了一系列重要的协议约定:返回的response必须包含type、msg_id、session_id、role、content、create_time、meta这七个字段,这直接对应了第2章定义的消息模型。也就是说,不管未来接什么模型、什么Agent,所有返回给前端的数据都遵循同一套协议格式。

在MockAgent里注册并启动服务:

# backend/app/main.py from fastapi import FastAPI from app.api.ws import router as ws_router from app.agents.registry import agent_registry from app.agents.mock_agent import MockAgent app = FastAPI(title="Agent Chat Platform") # 注册Agent agent_registry.register(MockAgent()) # 挂载WebSocket路由 app.include_router(ws_router) @app.get("/health") async def health_check(): return {"status": "ok"}

到了这一步,后端骨架已经立住了。下一次迭代,把MockAgent的process方法内部实现换成真正的模型调用逻辑,就是接入大模型的过程。从MockAgent到真实Agent的替换成本被控制在了“只改一个方法内部”的范围内,这正是分层设计带来的收益。

5. 前端第一版:能对话的最简界面

5.1 页面骨架与消息渲染

后端的服务跑通了,接下来用最原始的方式验证整条链路——一个纯HTML页面加原生JavaScript,不引入任何前端框架。这个选择是刻意的:地基阶段最重要的是验证通信链路,而不是展示前端工程能力。用React、Vue搭一套完整工程,光脚手架初始化就要十几分钟,而纯HTML五分钟就能完成,并且一样能验证整条链路的连通性。

第一版页面只需要三个部分:消息展示区、输入框、发送按钮。HTML骨架如下:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>AI智能体对话平台 - 开发版</title> <style> body { font-family: system-ui, sans-serif; max-width: 800px; margin: 40px auto; } #messages { border: 1px solid #ddd; height: 500px; overflow-y: auto; padding: 16px; } .msg { margin: 8px 0; padding: 8px 12px; border-radius: 8px; } .user { background: #e8f4fd; text-align: right; } .assistant { background: #f5f5f5; text-align: left; } .input-row { display: flex; margin-top: 12px; } #input { flex: 1; padding: 10px; font-size: 14px; } #send { padding: 10px 24px; margin-left: 8px; cursor: pointer; } </style> </head> <body> <h2>AI智能体对话平台 · 地基版</h2> <div id="messages"></div> <div class="input-row"> <input id="input" type="text" placeholder="输入你的消息..."> <button id="send">发送</button> </div> <script src="app.js"></script> </body> </html>

消息渲染函数是页面的核心逻辑之一。每一帧消息根据role的不同渲染成不同的样式,用户消息靠右、助手消息靠左,视觉上区分清晰。这里有个细节:渲染消息内容统一用textContent而不是innerHTML,这是安全问题。如果Agent返回的内容里包含一段<script>或者HTML标签,用innerHTML插入会被浏览器执行,一旦某个API或数据库的内容不可信,这就是XSS注入的入口。用textContent可以让内容原样展示,从根上杜绝这类风险。

5.2 WebSocket连接的建立与断线处理

前端JavaScript的核心是建立WebSocket连接,这个逻辑要写得足够健壮,因为真实环境里用户会刷新页面、切换网络、锁屏休眠,连接随时可能断开。

// backend/../frontend/app.js const sessionId = 'session-' + Date.now(); let ws = null; let reconnectAttempts = 0; function connectWebSocket() { ws = new WebSocket(`ws://localhost:8000/ws/${sessionId}`); ws.onopen = () => { console.log('WebSocket已连接'); reconnectAttempts = 0; // 连接建立后,立即发送心跳,确认连接可用 ws.send(JSON.stringify({ type: 'ping', time: Date.now() })); }; ws.onmessage = (event) => { const data = JSON.parse(event.data); if (data.type === 'pong') { return; // 心跳响应,忽略 } if (data.type === 'message') { renderMessage(data); } }; ws.onclose = () => { // 指数退避重连:1s, 2s, 4s, 8s... 最多等待30s reconnectAttempts = Math.min(reconnectAttempts + 1, 5); const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); console.log(`连接断开,${delay / 1000}s 后重连`); setTimeout(connectWebSocket, delay); }; ws.onerror = (error) => { console.error('WebSocket错误', error); ws.close(); // 触发onclose中的重连逻辑 }; } function renderMessage(msg) { const container = document.getElementById('messages'); const el = document.createElement('div'); el.className = `msg ${msg.role}`; el.textContent = msg.content; container.appendChild(el); container.scrollTop = container.scrollHeight; } function sendMessage() { const input = document.getElementById('input'); const content = input.value.trim(); if (!content) return; ws.send(JSON.stringify({ type: 'message', content })); input.value = ''; } document.getElementById('send').addEventListener('click', sendMessage); document.getElementById('input').addEventListener('keydown', (e) => { if (e.key === 'Enter') sendMessage(); }); // 初始化连接 connectWebSocket();

断线重连这一段是整个前端代码里最值得写清楚的地方。我用了指数退避算法:第一次断开等1秒重连,第二次等2秒,第三次4秒,依次翻倍,封顶30秒。这个策略比固定间隔重连聪明得多:短时间内网络抖动时快速恢复,持续故障时也不会高频轰炸服务器。我之前的项目里踩过一个坑,当时重连没有退避逻辑,服务端短暂重启时所有客户端同时在1秒内冲击重连,直接把服务打满了。用上指数退避后,这个压力被自动分散了。

心跳逻辑同样值得说明。我选择在连接建立后立即发一条ping,后续前端定时每30秒发一条。这个机制一方面保持了连接的活跃状态,另一方面能快速感知连接是否假死。所谓假死,就是TCP连接从系统层看还活着,但实际已经无法传递数据,这个状态用底层的连接事件检测不到,只能靠应用层心跳来兜底。

5.3 将前端与后端联调打通

准备工作都完成后,联调步骤如下:

第一步,启动后端服务。在backend目录下激活虚拟环境,执行uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload,终端出现Uvicorn running on http://0.0.0.0:8000表示服务正常。

第二步,打开前端页面。用浏览器直接双击index.html文件打开即可,注意不需要起前端开发服务器,因为页面代码全部是静态的,WebSocket连接是直接指向ws://localhost:8000的。

第三步,在输入框里输入“你好”,点击发送。正常情况下,页面上先出现右侧蓝色背景的“你好”,紧接着出现左侧灰色背景的“收到消息:你好。当前是地基版MockAgent,接入模型后我会变聪明。”,说明整条链路已经跑通了。

这条链路是全系统最重要的一条命脉:浏览器 → WebSocket → FastAPI端点 → AgentRegistry → MockAgent → 返回结果 → WebSocket推送 → 前端渲染。你在这个阶段所做的事情,本质上与日后接入真实大模型后的流程完全一致,只是“处理消息”这个环节从MockAgent的规则逻辑换成了模型调用。

第一次把这条链路跑通的时候,那种“虽然功能很蠢,但整条链路完全掌握在自己手里”的感觉,比直接调通一个成熟SDK要有成就感得多。

6. 联调实测与常见问题排查记录

6.1 联调时最常踩的五个坑

我把这段时间联调现实里“最常见的问题”整理成了表格,每一个都是真实踩过、真实解决过的。

问题现象根因定位解决方法
前端控制台报Failed to construct 'WebSocket'连接地址写错,或使用了HTTPS页面访问HTTP服务确认使用ws://前缀,页面与服务的协议需匹配(开发阶段统一用http)
连接被拒绝WebSocket connection failed后端没启动,或端口不一致先访问/health确认服务状态,再检查端口是否与前端代码一致
连接成功后立即断开FastAPI默认只允许同源请求,跨域端口不同被拦截给FastAPI配置allow_origins=["*"](开发阶段),或在前端起代理
发送消息后没有任何响应消息格式与后端解析不匹配打开控制台Network面板,检查WebSocket帧的原始内容,确认type和content字段名
几分钟后连接自动断开未实现心跳机制,被中间层超时回收前端30秒发一次ping,后端回pong,保持连接活跃

其中跨域问题是新手最常遇到、最难理解的坑。浏览器安全策略要求WebSocket连接地址的“协议、域名、端口”三者与页面地址一致,否则浏览器会阻止连接。开发阶段页面直接双击打开,地址是file://协议,连接目标却是http://localhost:8000,天然跨域。

解决方式是在FastAPI应用里配置允许跨域:

# backend/app/main.py from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], )

这里的allow_origins=["*"]在生产环境不能这么用,它等于允许任何网站访问你的服务。开发阶段图省事无所谓,上线前一定要改成实际的域名白名单。这是我特别想提醒的:开发阶段的“方便”和高安全性的“严谨”是有冲突的,别忘了在部署前做一次收敛。

6.2 排查链路问题的三层思路

联调过程中遇到问题,最忌讳的是凭感觉东改一下西试一下。我总结了一套“三层排查法”,几乎能解决链路类的所有问题。

第一层,看传输。打开浏览器开发者工具,切到Network面板,找到WebSocket连接,点开Messages标签页。你能看到客户端和服务端互发的所有原始帧。出问题时先看这里:消息有没有发出去?服务端有没有回?回的是什么?这一步能快速定位问题在“发”还是“收”还是“没发”。

第二层,看日志。在服务端代码的关键位置加日志,比如连接建立时、收到消息时、Agent处理完成时、推送消息时。我常用的一行日志是print(f"{time.time()} [WS] session={session_id} recv={raw_data}"),联调时这些日志极其宝贵。初版代码里有这行日志,能帮你准确判断服务端到底收到了什么格式的消息、哪个环节出了异常。

第三层,看协议。如果传输正常、服务端也收到了数据,但前端渲染出来不对,那大概率是协议字段对不上。把服务端返回的JSON与前端解析字段的代码放在一起对照,逐字段检查type、role、content的命名是否一致。曾经有人调试半天,最后发现后端返回的字段叫create_time,前端读的是createTime,就这么一个中划线风格之差,消息始终显示不出来。

提示:WebSocket联调时,浏览器开发者工具的Network面板是你的第一现场。它能看到所有帧的原始数据,比任何调试工具都直观。如果前端代码框架比较复杂,还可以先在测试页面里直接把WebSocket逻辑单独调试,排除页面业务代码的干扰。

这周我在本机实测下来,整套“MockAgent版”的地基从零搭完、联调跑通,大概需要一个下午的时间。跑通之后,一个非常直观的感觉是:系统的边界已经清晰了,接下来的迭代方向也不再模糊——该在哪个位置替换模型调用逻辑、该在哪里扩展多Agent路由、该在哪里补持久化,每一步都清楚明了。

我还想单独补充一点实际体会:很多做智能体开发的朋友,包括我早期也是,总想一上来就把大模型接好,让Agent真正“聪明”起来。但地基阶段,克制才是最难得的品质。MockAgent看起来很笨,但它把全链路验证清楚了,把协议固定下来了,把分层理清晰了。这些东西不比写一段惊艳的Agent逻辑简单,但它们的价值恰恰体现在后续迭代里的每一次顺畅改动中。那个“以后总会用到的机会”,很快就会来了——下一篇,我们就要把这个MockAgent换成真正的大模型大脑,把流式输出、上下文管理、工具调用逐步加进来。地基已经牢固,是时候起高楼了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询