一、前言
在网页端对话框输入一句话并点击发送后,系统并不是简单地把文本转发给大模型,而是会经历一套完整的处理流程:
- 前端获取用户输入;
- 通过 HTTP SSE 发起聊天请求;
- Spring Controller 校验请求和权限;
- 创建或恢复会话;
- 保存用户消息;
- 进入 Agent Runtime;
- 准备工作区、上下文和工具;
- 选择模型 Provider;
- 调用外部大模型;
- 根据模型结果执行工具或继续对话;
- 将模型输出转换成 SSE;
- 前端实时显示回答。
本文按照当前工作区的实际源码,对这条链路进行说明。
注意:当前
openclaw-web网页对话使用的是POST /v1/chat/completions,不是 Gateway WebSocket 的chat.send。不过两条链路最终都会进入同一个ChatAgentBridge和AgentRunner。
二、整体架构图
#mermaid-svg-4hcHtBzA27U5QGZe{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-4hcHtBzA27U5QGZe .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-4hcHtBzA27U5QGZe .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-4hcHtBzA27U5QGZe .error-icon{fill:#552222;}#mermaid-svg-4hcHtBzA27U5QGZe .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-4hcHtBzA27U5QGZe .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-4hcHtBzA27U5QGZe .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-4hcHtBzA27U5QGZe .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-4hcHtBzA27U5QGZe .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-4hcHtBzA27U5QGZe .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-4hcHtBzA27U5QGZe .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-4hcHtBzA27U5QGZe .marker{fill:#333333;stroke:#333333;}#mermaid-svg-4hcHtBzA27U5QGZe .marker.cross{stroke:#333333;}#mermaid-svg-4hcHtBzA27U5QGZe svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-4hcHtBzA27U5QGZe p{margin:0;}#mermaid-svg-4hcHtBzA27U5QGZe .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-4hcHtBzA27U5QGZe .cluster-label text{fill:#333;}#mermaid-svg-4hcHtBzA27U5QGZe .cluster-label span{color:#333;}#mermaid-svg-4hcHtBzA27U5QGZe .cluster-label span p{background-color:transparent;}#mermaid-svg-4hcHtBzA27U5QGZe .label text,#mermaid-svg-4hcHtBzA27U5QGZe span{fill:#333;color:#333;}#mermaid-svg-4hcHtBzA27U5QGZe .node rect,#mermaid-svg-4hcHtBzA27U5QGZe .node circle,#mermaid-svg-4hcHtBzA27U5QGZe .node ellipse,#mermaid-svg-4hcHtBzA27U5QGZe .node polygon,#mermaid-svg-4hcHtBzA27U5QGZe .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-4hcHtBzA27U5QGZe .rough-node .label text,#mermaid-svg-4hcHtBzA27U5QGZe .node .label text,#mermaid-svg-4hcHtBzA27U5QGZe .image-shape .label,#mermaid-svg-4hcHtBzA27U5QGZe .icon-shape .label{text-anchor:middle;}#mermaid-svg-4hcHtBzA27U5QGZe .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-4hcHtBzA27U5QGZe .rough-node .label,#mermaid-svg-4hcHtBzA27U5QGZe .node .label,#mermaid-svg-4hcHtBzA27U5QGZe .image-shape .label,#mermaid-svg-4hcHtBzA27U5QGZe .icon-shape .label{text-align:center;}#mermaid-svg-4hcHtBzA27U5QGZe .node.clickable{cursor:pointer;}#mermaid-svg-4hcHtBzA27U5QGZe .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-4hcHtBzA27U5QGZe .arrowheadPath{fill:#333333;}#mermaid-svg-4hcHtBzA27U5QGZe .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-4hcHtBzA27U5QGZe .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-4hcHtBzA27U5QGZe .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4hcHtBzA27U5QGZe .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-4hcHtBzA27U5QGZe .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4hcHtBzA27U5QGZe .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-4hcHtBzA27U5QGZe .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-4hcHtBzA27U5QGZe .cluster text{fill:#333;}#mermaid-svg-4hcHtBzA27U5QGZe .cluster span{color:#333;}#mermaid-svg-4hcHtBzA27U5QGZe div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-4hcHtBzA27U5QGZe .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-4hcHtBzA27U5QGZe rect.text{fill:none;stroke-width:0;}#mermaid-svg-4hcHtBzA27U5QGZe .icon-shape,#mermaid-svg-4hcHtBzA27U5QGZe .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4hcHtBzA27U5QGZe .icon-shape p,#mermaid-svg-4hcHtBzA27U5QGZe .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-4hcHtBzA27U5QGZe .icon-shape .label rect,#mermaid-svg-4hcHtBzA27U5QGZe .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4hcHtBzA27U5QGZe .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-4hcHtBzA27U5QGZe .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-4hcHtBzA27U5QGZe :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
用户在网页输入消息
App.vue submit
chat.sendMessage
streamChat
POST /v1/chat/completions
OpenAiChatController
鉴权与请求解析
SessionStore 创建或查找会话
SessionTranscriptStore 保存用户消息
ChatAgentBridgeImpl
AgentRunner.runAsync
准备 Sandbox 和上下文
加载工具与技能
ModelProviderRegistry
OpenAICompatibleProvider
外部大模型 API
是否需要调用工具
ToolRegistry 执行工具
生成最终回答
OpenAiChatController SSE
浏览器解析 delta
页面显示回答
三、第一步:用户提交消息
前端页面位于:
openclaw-web/src/App.vueopenclaw-web/src/stores/chat.ts
用户点击发送后,页面调用:
text
submit()
↓
chat.sendMessage(value, activeSessionKey)
对应流程可以简化为:
ts
async function submit() {
const value = draft.value.trim()
if (!value) return
draft.value = ''
await chat.sendMessage(value, activeSessionKey.value)
}
sendMessage() 会先在前端做三件事:
- 把用户消息加入消息列表;
- 创建一个空的 assistant 消息作为占位;
- 设置
isGenerating = true,表示当前正在生成回答。
此时页面上通常会先显示用户刚刚输入的内容,以及一个正在生成中的空白回答区域。
四、第二步:前端发起 HTTP SSE 请求
当前网页聊天调用的是:
http
POST /v1/chat/completions
核心请求体如下:
json
{
"model": "default",
"sessionKey": "main",
"messages": [
{
"role": "user",
"content": "你好,请介绍一下这个项目"
}
],
"stream": true
}
前端通过 fetch() 发起请求:
ts
fetch('/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream'
},
body: JSON.stringify({ ...request, stream: true })
})
为什么使用 SSE?
因为大模型通常不是一次性返回完整答案,而是逐字或逐段返回。SSE 可以让用户在模型还没有完全生成结束时,就看到已经生成的内容。
请求响应头主要包括:
http
Content-Type: text/event-stream
Cache-Control: no-cache, no-transform
Connection: keep-alive
五、第三步:Spring Controller 接收请求
请求进入:
text
openclaw-app/src/main/java/com/openclaw/app/openai/OpenAiChatController.java
对应方法:
text
chatCompletions()
Controller 首先进行鉴权:
text
Authorization: Bearer <gateway-token>
↓
extractBearerToken()
↓
AuthService.authorize()
如果鉴权失败,接口返回:
json
{
"error": {
"message": "Unauthorized",
"type": "authentication_error"
}
}
HTTP 状态码为:
text
401 Unauthorized
如果没有有效的用户消息,则返回:
text
400 Bad Request
六、第四步:创建或恢复会话
Controller 会根据 sessionKey 查找当前会话:
text
SessionStore.findBySessionKey(sessionKey)
如果查找不到,就创建一个新会话:
text
SessionStore.createSession(sessionKey, cwd)
一个会话通常包含以下信息:
| 字段 | 说明 |
|---|---|
sessionId |
会话唯一 ID |
sessionKey |
对外使用的会话 key,例如 main |
model |
当前会话使用的模型 |
cwd |
当前工作目录 |
status |
会话运行状态 |
createdAt |
创建时间 |
updatedAt |
更新时间 |
之后 Controller 会更新当前会话使用的模型:
text
sessionStore.updateSession(sessionId, session -> {
session.setModel(model);
})
七、第五步:保存用户消息
用户输入会被保存到 transcript:
text
SessionTranscriptStore.appendMessage(
sessionId,
"user",
rawUserMessage
)
消息不会只保存在浏览器内存中,而是会写入 OpenClaw 的会话记录文件。
整体关系如下:
text
sessionKey
↓
SessionStore
↓
sessionId
↓
SessionTranscriptStore
↓
sessionId.jsonl
这样做的好处是:
- 页面刷新后可以继续查看历史;
- Agent 可以读取当前会话上下文;
- 后续请求可以关联同一个 session;
- 可以记录 token 用量和运行状态。
八、第六步:进入 ChatAgentBridge
Controller 不直接调用模型,而是把请求交给:
text
ChatAgentBridge.runChat()
实现位于:
text
openclaw-app/src/main/java/com/openclaw/app/bridge/ChatAgentBridgeImpl.java
桥接层主要负责:
- 解析模型 ID;
- 处理模型别名;
- 把 OpenAI 消息转换成内部消息对象;
- 组装
AgentRunContext; - 调用
AgentRunner.runAsync()。
可以把 Bridge 理解成 HTTP 层和 Agent Runtime 之间的适配层:
text
HTTP ChatCompletionRequest
↓
ChatRunRequest
↓
AgentRunContext
↓
AgentRunner
这样可以让 HTTP 接口、Gateway WebSocket 接口和其他内部调用共享同一套 Agent 执行能力。
九、第七步:AgentRunner 开始执行
AgentRunner 是真正执行 Agent 逻辑的核心组件,位于:
text
openclaw-agent/src/main/java/com/openclaw/agent/runtime/AgentRunner.java
入口通常是:
text
AgentRunner.runAsync(context)
它会异步调用真正的运行逻辑:
text
runAsync()
↓
CompletableFuture.supplyAsync()
↓
run(context)
这样可以避免长时间占用 HTTP 请求线程。
十、第八步:准备 Agent 上下文
AgentRunner 开始执行后,不会立即把用户原文发送给模型,而是先准备运行环境。
主要准备内容包括:
1. Sandbox
根据当前配置和会话信息准备 Sandbox 环境,用于隔离文件、命令和工具操作。
2. 工作区上下文
读取项目工作区相关内容,例如:
- Bootstrap 文件;
- Agent 指令;
- 用户配置;
- 工具说明;
- Memory 文件;
- Skills 信息。
3. 系统提示词
系统提示词会和用户消息组合成完整的 Agent 上下文。
最终发送给模型的内容通常类似:
text
系统提示词
+ 工作区上下文
+ 会话历史
+ 工具定义
+ 用户消息
所以模型看到的并不只是用户输入的那一句话。
十一、第九步:选择模型 Provider
AgentRunner 根据 modelId 查找模型 Provider:
text
ModelProviderRegistry.resolve(modelId)
相关代码位于:
text
openclaw-agent/src/main/java/com/openclaw/agent/models/ModelProviderRegistry.java
模型 ID 可能包含 Provider 前缀,例如:
text
openai/gpt-4o
anthropic/claude
qwen/qwen-max
Registry 会根据模型 ID 找到对应实现。
常见 Provider 通常会统一适配为 OpenAI 兼容请求格式,由:
text
OpenAICompatibleProvider.chatStream()
发起外部请求。
十二、第十步:调用外部大模型 API
Provider 会通过 HTTP 客户端向外部大模型发送请求:
text
AgentRunner
↓
OpenAICompatibleProvider
↓
OkHttp EventSource
↓
外部模型 /chat/completions
外部模型通常返回 SSE 数据,例如:
text
data: {"choices":[{"delta":{"content":"你好"}}]}
data: {"choices":[{"delta":{"content":",这是一个 Java 项目。"}}]}
Provider 收到每个增量后,会调用内部监听器:
text
listener.onText(delta)
然后把文本增量继续传给上层 AgentRunner。
十三、第十一步:模型可能调用工具
模型并不一定直接回答用户问题。
例如用户输入:
text
查看当前项目有哪些 Java 模块
模型可能先返回工具调用请求:
json
{
"tool": "exec",
"arguments": {
"command": "dir"
}
}
此时流程变成:
text
模型返回 tool call
↓
AgentRunner.executeTool()
↓
ToolRegistry 查找工具
↓
执行工具
↓
得到工具结果
↓
把工具结果加入对话上下文
↓
再次调用模型
完整过程可能是:
text
用户 → 模型
模型 → exec 工具
exec 工具 → 返回目录列表
目录列表 → 模型
模型 → 最终回答
可能被调用的工具包括:
- 文件读取工具;
- 文件写入工具;
- Shell/命令工具;
- 浏览器工具;
- 搜索工具;
- 技能工具;
- 插件工具。
因此,一次用户输入可能对应多次模型调用。
十四、第十二步:Agent 产生文本增量
当模型输出普通文本时,数据会沿着以下方向回传:
text
外部模型 delta
↓
OpenAICompatibleProvider.onText()
↓
AgentRunner listener.onDelta()
↓
ChatAgentBridge listener.onDelta()
↓
OpenAiChatController listener.onDelta()
Controller 把增量包装成 OpenAI 兼容格式:
json
{
"id": "chatcmpl-uuid",
"object": "chat.completion.chunk",
"model": "openclaw",
"choices": [
{
"index": 0,
"delta": {
"content": "你好"
},
"finish_reason": null
}
]
}
然后通过 SseEmitter 写回浏览器:
text
data: {"choices":[{"delta":{"content":"你好"}}]}
十五、第十三步:浏览器解析 SSE 并更新页面
前端 streamChat() 使用:
ReadableStream;TextDecoder;- SSE 行解析逻辑。
它会提取:
text
data → choices[0] → delta → content
然后调用:
ts
onDelta(delta)
Store 再把文本追加到 assistant 消息:
ts
const target = messages.value[assistantIndex]
if (target) {
target.content += delta
}
因此用户可以看到类似这样的实时效果:
text
你
你好
你好,这是
你好,这是一个
你好,这是一个 Java
你好,这是一个 Java 项目。
十六、第十四步:请求结束
当 Agent 执行完成后,后端会:
- 发送最后一个完成 chunk;
- 发送
finish_reason: "stop"; - 发送 SSE 结束标记;
- 调用
emitter.complete()。
结束标记为:
text
data: [DONE]
前端读到 [DONE] 后退出读取循环,并将消息状态改为:
text
complete
如果中途发生异常,则会进入错误流程。
十七、异常处理流程
1. 鉴权失败
text
AuthService.authorize() 失败
↓
HTTP 401
↓
前端显示鉴权错误
2. 请求参数错误
text
messages 缺失或为空
↓
HTTP 400
↓
前端显示请求参数错误
3. 模型调用失败
text
Provider 请求失败
↓
AgentRunner 捕获异常
↓
onError()
↓
SSE 错误 chunk
↓
前端显示错误信息
4. 用户主动停止
前端调用 AbortController.abort():
text
用户点击停止
↓
AbortController.abort()
↓
fetch 流被取消
↓
前端显示"已停止生成"
5. 工具执行失败
工具失败一般会封装为工具结果返回给 Agent:
text
工具执行失败
↓
ToolResult.fail()
↓
返回模型继续判断
工具失败不一定会导致整次对话立即结束,模型可能根据错误结果继续尝试或向用户说明原因。
十八、会话数据最终保存在哪里
当前系统主要保存两类数据。
1. 会话元数据
用于保存:
- session ID;
- session key;
- 当前模型;
- 工作目录;
- 运行状态;
- 更新时间。
2. 会话消息 transcript
用于保存:
- 用户消息;
- assistant 消息;
- tool 消息;
- 运行过程中的消息记录。
简化后的持久化结构:
text
~/.openclaw/
├── state/
│ └── agents/
│ └── {agentId}/
│ └── sessions/
│ └── sessions.json
└── sessions/
└── {sessionId}.jsonl
十九、如果使用 Gateway WebSocket,会有什么不同?
系统还保留了 Gateway WebSocket 的聊天入口:
json
{
"type": "req",
"id": "chat-1",
"method": "chat.send",
"params": {
"sessionKey": "main",
"message": "你好"
}
}
这条链路是:
text
WebSocket 客户端
↓
GatewayWebSocketHandler
↓
GatewayMethodRouter
↓
ChatMethodHandler.handleChatSend()
↓
ChatAgentBridge
↓
AgentRunner
↓
ModelProvider
区别在于返回方式:
| 项目 | 当前网页聊天 | Gateway WebSocket |
|---|---|---|
| 入口 | POST /v1/chat/completions |
WebSocket chat.send |
| 流式协议 | HTTP SSE | WebSocket event |
| 文本事件 | OpenAI chunk | chat event |
| 工具事件 | 隐藏在 HTTP 流程内部 | agent event |
| Agent Runtime | 相同 | 相同 |
| Model Provider | 相同 | 相同 |
所以两者只有最外层入口不同,底层核心执行逻辑基本一致。
二十、总结
在网页对话框输入一句话后,完整流程可以概括为:
text
用户输入
↓
Vue 页面
↓
HTTP SSE
↓
OpenAiChatController
↓
鉴权和会话管理
↓
ChatAgentBridge
↓
AgentRunner
↓
上下文、工具、Sandbox
↓
ModelProvider
↓
外部大模型
↓
工具循环或最终回答
↓
SSE 增量返回
↓
Vue 页面实时显示
最重要的结论有三个:
- 当前网页对话入口是 HTTP
/v1/chat/completions,不是 Gateway WebSocket; - HTTP 接口内部仍然复用了 Gateway 的会话、鉴权和 Agent 能力;
- 一句话可能触发多轮模型调用、工具执行和上下文更新,最终才生成回答。
因此,OpenClaw Java 的一次对话请求,本质上是:
前端流式请求 + Gateway 核心能力 + Agent Runtime + Model Provider + 工具循环 + SSE 流式响应。