最近一段时间封闭结束,开始系统性整理。这篇文章是系列第一篇:用一次只读查询,把从界面到 Trace 的整条链路先跑通。
用户说:"查一下生产环境 order-service 最近的错误日志。"
最终只看到一段回答,背后至少经过身份、会话、意图、模型、工具、流式事件、持久化和观测。任何一跳出错,都可能表现成"Agent 答错了"。这篇先把整条链路跑通,后面几篇再分头细说。
案例 OpsPilot(企业运维助手,虚构):
- 只读工具:
search_resource、query_logs - 知识工具:
search_knowledge_base - 写工具:
restart_instance,必须 HITL(Human-in-the-Loop,人在回路确认) - 技术栈:Node.js + TypeScript + Vue 3
一张总图
图上每一块管一件事,别混:
- 模型负责读懂意思,并给出结构化提案。
- Runtime 负责推进步骤、卡预算、处理中断。
- Tool Runtime 负责 schema(参数长什么样)、鉴权、幂等(同一操作做一次和做多次)和真正执行。
- 会话服务负责身份、Run 生命周期和跟前端的协议。
- Trace/Eval 负责事后说清「到底哪一跳错了」。
请求怎么进 Runtime
会话 API 入口
前端不应直连模型。它调用自己的会话 API:
bash
POST /api/conversations/conv_123/runs
Authorization: Bearer <user-token>
Content-Type: application/json
Idempotency-Key: 2d7e45b8
{
"message": "查一下生产环境 order-service 最近的错误日志"
}
RequestContext 从哪来
服务入口中间件从可信登录态拼出上下文。尤其是 tenantId 和 userId,别从模型参数或用户原文里读:
ts
export type RequestContext = { // 入口鉴权后的请求上下文
requestId: string // 本请求 ID,日志与排障用
traceId: string // 全链路追踪 ID,跨会话/工具/模型共用
tenantId: string // 租户;检索过滤与数据隔离以此为准
userId: string // 当前用户;工具鉴权读这里,不读模型参数
roles: string[] // 角色,用于 ACL / RAG 可见范围
scopes: string[] // 允许调用的工具或资源范围
deadlineAt: number // 整次 Run 截止时间戳,超时取消
}
会话服务创建 runId,写入用户消息,然后把 Run 交给 Runtime。HTTP 连接只是订阅进度,不是任务状态本身。
只读时序:从提案到回灌
场景 :用户问「查一下生产环境 order-service 最近的错误日志」。
这是只读链路,不需要 HITL;通常会先定位资源,再查日志,最后给出带证据的回答。按谁干什么读图:
| 参与方 | 职责 |
|---|---|
| VueUI | 发起 POST,订阅 SSE,渲染步骤与增量文本 |
| SessionAPI | 验身份、建 Run、转发事件;不直连模型 |
| AgentRuntime | 推进 ReAct:调模型、派工具、收结果、决定是否结束 |
| ModelGateway | 统一调用 LLM,返回 tool_calls 或最终文本 |
| ToolRuntime | 校验参数、按 RequestContext 鉴权、执行业务 API |
| Observability | 记录 span、版本、Token,供事后归因 |
切记:每个
tool_call_id必须有对应结果。工具失败也要返回结构化错误,否则下一轮模型既无法自我修正,也可能违反模型 API 的消息约束。
关键几跳:
- 入口 :前端
POST run,会话服务带着可信RequestContext启动 Runtime,同时先推run_started,让 UI 立刻有反馈。 - 第一轮模型 :Runtime 把消息和工具 schema 交给模型。模型提出
search_resource------此时还没查库,只是「提案」。 - 工具执行 :Runtime 发
tool_started给前端,再交给 Tool Runtime 真正调用。身份用上下文里的tenantId/userId,不信模型参数里的身份字段。 - 结果回灌 :工具结果变成
ToolMessage再进模型。每个tool_call_id都必须有一条结果;失败也要结构化回灌,否则下一轮对不齐消息协议。 - 第二轮工具 :模型根据资源信息再提
query_logs,同样走 Tool Runtime,拿到结构化日志摘要。 - 收尾 :模型给出最终自然语言答案;Runtime 落 Trace,会话服务推
text_delta+done。HTTP 断开不代表 Run 不存在,状态以会话服务为准。
一句话:模型只提案、Tool Runtime 才执行;前端只订事件、别自己猜「是不是在调工具」;写操作别走这条「连续自动跑」的路,要进下一节的 HITL。
事件、写操作 HITL 与落库
前端、落库和观测最好围着同一组业务事件转。事件类型可以先定成下面这一组:
ts
export type RunEvent = // Run 业务事件联合
| {
type: 'run_started' // 事件:Run 已开始
runId: string // 本次 Run ID
sequence: number // 事件序号,去重/续传用
}
| {
type: 'text_delta' // 事件:增量文本
runId: string // 所属 Run
sequence: number // 事件序号
delta: string // 增量文本,不是全量覆盖
}
| {
type: 'tool_started' // 事件:工具开始执行
runId: string // 所属 Run
sequence: number // 事件序号
callId: string // 与 tool_finished 配对
tool: string // 工具名,给步骤条展示
}
| {
type: 'tool_finished' // 事件:工具结束
runId: string // 所属 Run
sequence: number // 事件序号
callId: string // 对应 tool_started
status: 'success' | 'error' // 执行结果状态
summary: string // 给前端的短摘要,不是原始大结果
}
| {
type: 'interrupt' // 事件:HITL 等待确认
runId: string // 所属 Run
sequence: number // 事件序号
interruptId: string // 恢复时带回,防止过期确认
payload: unknown // 确认卡内容:风险说明、参数摘要等
}
| {
type: 'done' // 事件:Run 正常结束
runId: string // 所属 Run
sequence: number // 事件序号
messageId: string // 落库后的助手消息 ID
}
| {
type: 'error' // 事件:Run 失败
runId: string // 所属 Run
sequence: number // 事件序号
code: string // 可分类错误码,勿回灌堆栈
}
sequence 用来去重和以后补发;第一版就算还没做事件重放,字段也先留着。别让前端靠解析自然语言去猜「现在是不是在调工具」。
写操作为什么必须换一条路
如果用户说:"重启这个异常实例"。模型只能提出动作,不能自己给自己开权限:
恢复时至少重做四件事:
- 校验
interruptId仍 pending; - 按当前 RequestContext 重新鉴权;
- 重新读取资源,防止确认期间状态变化;
- 用幂等键避免双击和网络重试造成重复执行。
具体实现见后面几篇;多轮、SSE、HITL 和工具 Runtime 会分开写。
Run 最少要存什么
SSE 可能断,Run 还在跑。所以库里至少要有一张「状态小票」RunRecord:随时能查这笔请求卡在哪、用了哪版 Prompt/模型。它不是聊天全文,也不是工具吐出来的大结果。
ts
type RunRecord = { // Run 落库最小字段
id: string // Run 主键
conversationId: string // 所属会话
tenantId: string // 租户隔离字段,查询必带
status: // Run 生命周期状态
| 'queued' // 已入队,等待 Worker
| 'running' // 模型/工具执行中
| 'waiting_human' // HITL 等待确认
| 'succeeded' // 正常结束
| 'failed' // 失败结束
| 'cancelled' // 用户或系统取消
lastSequence: number // 已发出的最大事件序号(断线续传/去重)
promptVersion: string // 本轮 Prompt 版本,排障/回滚用
toolsetVersion: string // 工具集版本
model: string // 实际调用的模型名
createdAt: string // 创建时间
updatedAt: string // 最后状态变更时间
}
日志摘要可能很长:另存成 artifact(对象存储或附件表),Run 里只留引用。
Prompt、工具集、模型、知识库版本要能挂到 Trace 上;否则线上回退时说不清「当时到底用了哪一版」。
验收、排障与交付顺序
| 层 | 可验证的完成标准 |
|---|---|
| Vue UI | 流式增量可渲染;工具步骤可折叠;停止与 HITL 可操作 |
| 会话服务 | 用户消息和 Run 落库;断开可查结果;取消语义明确 |
| Runtime | 最大步数;重复调用检测;可中断;状态可恢复 |
| Tool Runtime | schema、scope、超时、错误分类、幂等与审计 |
| RAG | 租户过滤;引用可回原文;文档更新可失效 |
| 模型网关 | 超时与 429 分类;模型版本、Token 和成本可观测 |
| 安全 | 外部内容不改变权限;高风险操作有独立控制点 |
| 质量 | 一条 traceId 可还原轨迹;修改前后可跑同一 Eval |
| 发布 | 健康检查、灰度、回滚和告警入口可用 |
一个故障怎么定位
用户反馈:"我明明查生产,为什么返回测试环境?"
按 Trace 找第一个错误点:
ts
// Trace 片段:意图对了,但工具参数环境错了------第一错误点在填参,不在库查询
{
traceId: 'tr_01', // 整次请求追踪 id
intent: { // 意图分析结果
type: 'query_logs', // 意图正确
environment: 'production' // 用户要生产
},
toolCalls: [ // 实际发出的工具调用
{
name: 'query_logs', // 工具选对了
args: { // 工具参数
service: 'order-service', // 服务正确
environment: 'test' // 实际调成了测试 → 修 Prompt/结构化约束
}
}
]
}
意图对了、工具参数错了,就修选工具/填参的 Prompt 或结构化约束;别先去改数据库查询。
交付顺序与业务验收
交付顺序没有行业标准。下面只是风险从低到高的建议,不是固定周数:
- 会话落库 + 文本 SSE。
- 一个只读工具 + ToolMessage 错误回灌。
- RAG 权限过滤 + 引用。
- 写工具 + HITL + 幂等审计。
- Eval 发布检查 + Trace 大盘。
- 模型降级、预算、灰度和回滚。
业务侧也不能省。一次完整上线至少能说清:
text
需求澄清 → 成功指标 → 只读原型 → 写操作与 HITL → 上线 → 采用率 / 用户反馈 → 回滚与复盘
成功指标先写业务结果(例如「值班能在工作台查完日志并点开引用」),再写模型指标。没有采用率和回滚记录,只能证明系统能跑,不能证明有人在用。
小结
这篇先钉住三件事:
- 模型提案,Tool Runtime 执行。
- 前端订事件,不猜状态。
- 写操作必须 HITL + 重鉴权。
文中的 OpsPilot、ID 和 Trace 都是可复现的示例。下一篇继续:《Agent 意图识别:从语义理解到稳定路由》。
希望这篇文章对你有所帮助、有所借鉴。