中文资讯 掘金 AI Thu, 22 Oct 2026 正文 ok 719 words

【封闭结束1】端到端交付:Agent 一次请求如何走完全链路

英文标题:【封闭结束1】端到端交付:Agent 一次请求如何走完全链路

用户说:“查一下生产环境 order-service 最近的错误日志。” 最终只看到一段回答,背后至少经过身份、会话、意图、模型、工具、流
打开原文相关 3 · 机会 1
media3.2 KB media589 B media6.8 KB

中文

最近一段时间封闭结束,开始系统性整理。这篇文章是系列第一篇:用一次只读查询,把从界面到 Trace 的整条链路先跑通。

用户说:“查一下生产环境 order-service 最近的错误日志。”

最终只看到一段回答,背后至少经过身份、会话、意图、模型、工具、流式事件、持久化和观测。任何一跳出错,都可能表现成“Agent 答错了”。这篇先把整条链路跑通,后面几篇再分头细说。

案例 OpsPilot(企业运维助手,虚构):

  • 只读工具:search_resource、query_logs
  • 知识工具:search_knowledge_base
  • 写工具:restart_instance,必须 HITL(Human-in-the-Loop,人在回路确认)
  • 技术栈:Node.js + TypeScript + Vue 3

一张总图

flowchart TD
    ui["Vue 对话 UI"] -->|"POST + SSE"| session["会话服务"]
    session --> auth["鉴权与 RequestContext"]
    auth --> runtime["Agent Runtime"]
    runtime --> intent["意图与上下文组装"]
    intent --> gateway["模型网关"]
    gateway --> model["LLM Provider"]
    runtime --> toolRuntime["Tool Runtime"]
    toolRuntime --> businessTools["业务 API / MCP"]
    runtime --> rag["RAG 检索"]
    rag --> vectorStore["向量库 / 全文索引"]
    runtime --> events["Run 事件与消息持久化"]
    events --> ui
    runtime --> trace["Trace / Metrics / Eval"]

图上每一块管一件事,别混:

  • 模型负责读懂意思,并给出结构化提案。
  • Runtime 负责推进步骤、卡预算、处理中断。
  • Tool Runtime 负责 schema(参数长什么样)、鉴权、幂等(同一操作做一次和做多次)和真正执行。
  • 会话服务负责身份、Run 生命周期和跟前端的协议。
  • Trace/Eval 负责事后说清「到底哪一跳错了」。

请求怎么进 Runtime

会话 API 入口

前端不应直连模型。它调用自己的会话 API:

POST /api/conversations/conv_123/runs
Authorization: Bearer <user-token>
Content-Type: application/json
Idempotency-Key: 2d7e45b8

{
  "message": "查一下生产环境 order-service 最近的错误日志"
}

RequestContext 从哪来

服务入口中间件从可信登录态拼出上下文。尤其是 tenantId 和 userId,别从模型参数或用户原文里读:

export type RequestContext = { 
  requestId: string   
  traceId: string     
  tenantId: string    
  userId: string      
  roles: string[]     
  scopes: string[]    
  deadlineAt: number  
}

会话服务创建 runId,写入用户消息,然后把 Run 交给 Runtime。HTTP 连接只是订阅进度,不是任务状态本身。

只读时序:从提案到回灌

场景 :用户问「查一下生产环境 order-service 最近的错误日志」。

这是只读链路,不需要 HITL;通常会先定位资源,再查日志,最后给出带证据的回答。按谁干什么读图:

参与方 职责
VueUI 发起 POST,订阅 SSE,渲染步骤与增量文本
SessionAPI 验身份、建 Run、转发事件;不直连模型
AgentRuntime 推进 ReAct:调模型、派工具、收结果、决定是否结束
ModelGateway 统一调用 LLM,返回 tool_calls 或最终文本
ToolRuntime 校验参数、按 RequestContext 鉴权、执行业务 API
Observability 记录 span、版本、Token,供事后归因
sequenceDiagram
    participant U as User
    participant V as VueUI
    participant S as SessionAPI
    participant R as AgentRuntime
    participant M as ModelGateway
    participant T as ToolRuntime
    participant O as Observability

    U->>V: 查询 order-service 错误日志
    V->>S: POST run
    S->>R: start(run, context)
    S-->>V: SSE run_started
    R->>M: messages + tool schemas
    M-->>R: search_resource(args)
    R-->>S: tool_started
    S-->>V: SSE tool_started
    R->>T: invoke(search_resource, args, context)
    T-->>R: exact resource
    R->>M: ToolMessage(result)
    M-->>R: query_logs(args)
    R->>T: invoke(query_logs, args, context)
    T-->>R: structured log summary
    R->>M: ToolMessage(result)
    M-->>R: final answer
    R->>O: spans + metrics + versions
    R-->>S: final message
    S-->>V: SSE text_delta + done

切记:每个 tool_call_id 必须有对应结果。工具失败也要返回结构化错误,否则下一轮模型既无法自我修正,也可能违反模型 API 的消息约束。

关键几跳:

  1. 入口 :前端 POST run,会话服务带着可信 RequestContext 启动 Runtime,同时先推 run_started,让 UI 立刻有反馈。
  2. 第一轮模型 :Runtime 把消息和工具 schema 交给模型。模型提出 search_resource——此时还没查库,只是「提案」。
  3. 工具执行 :Runtime 发 tool_started 给前端,再交给 Tool Runtime 真正调用。身份用上下文里的 tenantId/userId,不信模型参数里的身份字段。
  4. 结果回灌 :工具结果变成 ToolMessage 再进模型。每个 tool_call_id 都必须有一条结果;失败也要结构化回灌,否则下一轮对不齐消息协议。
  5. 第二轮工具 :模型根据资源信息再提 query_logs,同样走 Tool Runtime,拿到结构化日志摘要。
  6. 收尾 :模型给出最终自然语言答案;Runtime 落 Trace,会话服务推 text_delta + done。HTTP 断开不代表 Run 不存在,状态以会话服务为准。

一句话:模型只提案、Tool Runtime 才执行;前端只订事件、别自己猜「是不是在调工具」;写操作别走这条「连续自动跑」的路,要进下一节的 HITL。

事件、写操作 HITL 与落库

前端、落库和观测最好围着同一组业务事件转。事件类型可以先定成下面这一组:

export type RunEvent = 
  | {
      type: 'run_started'    
      runId: string          
      sequence: number       
    }
  | {
      type: 'text_delta'     
      runId: string          
      sequence: number       
      delta: string          
    }
  | {
      type: 'tool_started'   
      runId: string          
      sequence: number       
      callId: string         
      tool: string           
    }
  | {
      type: 'tool_finished'  
      runId: string          
      sequence: number       
      callId: string         
      status: 'success' | 'error' 
      summary: string        
    }
  | {
      type: 'interrupt'      
      runId: string          
      sequence: number       
      interruptId: string    
      payload: unknown       
    }
  | {
      type: 'done'           
      runId: string          
      sequence: number       
      messageId: string      
    }
  | {
      type: 'error'          
      runId: string          
      sequence: number       
      code: string           
    }

sequence 用来去重和以后补发;第一版就算还没做事件重放,字段也先留着。别让前端靠解析自然语言去猜「现在是不是在调工具」。

写操作为什么必须换一条路

如果用户说:“重启这个异常实例”。模型只能提出动作,不能自己给自己开权限:

stateDiagram-v2
    [*] --> Running
    Running --> WaitingHuman: restart_instance proposed
    WaitingHuman --> Running: approved + reauthorized
    WaitingHuman --> Cancelled: rejected or expired
    Running --> Completed
    Running --> Failed

恢复时至少重做四件事:

  • 校验 interruptId 仍 pending;
  • 按当前 RequestContext 重新鉴权;
  • 重新读取资源,防止确认期间状态变化;
  • 用幂等键避免双击和网络重试造成重复执行。

具体实现见后面几篇;多轮、SSE、HITL 和工具 Runtime 会分开写。

Run 最少要存什么

SSE 可能断,Run 还在跑。所以库里至少要有一张「状态小票」RunRecord:随时能查这笔请求卡在哪、用了哪版 Prompt/模型。它不是聊天全文,也不是工具吐出来的大结果。

type RunRecord = { 
  id: string                 
  conversationId: string     
  tenantId: string           
  status:                   
    | 'queued'               
    | 'running'              
    | 'waiting_human'        
    | 'succeeded'            
    | 'failed'               
    | 'cancelled'            
  lastSequence: number       
  promptVersion: string      
  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 找第一个错误点:

{
  traceId: 'tr_01', 
  intent: { 
    type: 'query_logs', 
    environment: 'production' 
  },
  toolCalls: [ 
    {
      name: 'query_logs', 
      args: { 
        service: 'order-service', 
        environment: 'test' 
      }
    }
  ]
}

意图对了、工具参数错了,就修选工具/填参的 Prompt 或结构化约束;别先去改数据库查询。

交付顺序与业务验收

交付顺序没有行业标准。下面只是风险从低到高的建议,不是固定周数:

  1. 会话落库 + 文本 SSE。
  2. 一个只读工具 + ToolMessage 错误回灌。
  3. RAG 权限过滤 + 引用。
  4. 写工具 + HITL + 幂等审计。
  5. Eval 发布检查 + Trace 大盘。
  6. 模型降级、预算、灰度和回滚。

业务侧也不能省。一次完整上线至少能说清:

需求澄清 → 成功指标 → 只读原型 → 写操作与 HITL → 上线 → 采用率 / 用户反馈 → 回滚与复盘

成功指标先写业务结果(例如「值班能在工作台查完日志并点开引用」),再写模型指标。没有采用率和回滚记录,只能证明系统能跑,不能证明有人在用。

小结

这篇先钉住三件事:

  1. 模型提案,Tool Runtime 执行。
  2. 前端订事件,不猜状态。
  3. 写操作必须 HITL + 重鉴权。

文中的 OpsPilot、ID 和 Trace 都是可复现的示例。下一篇继续:《Agent 意图识别:从语义理解到稳定路由》。

希望这篇文章对你有所帮助、有所借鉴。

English / 原文

最近一段时间封闭结束,开始系统性整理。这篇文章是系列第一篇:用一次只读查询,把从界面到 Trace 的整条链路先跑通。

用户说:“查一下生产环境 order-service 最近的错误日志。”

最终只看到一段回答,背后至少经过身份、会话、意图、模型、工具、流式事件、持久化和观测。任何一跳出错,都可能表现成“Agent 答错了”。这篇先把整条链路跑通,后面几篇再分头细说。

案例 OpsPilot(企业运维助手,虚构):

  • 只读工具:search_resource、query_logs
  • 知识工具:search_knowledge_base
  • 写工具:restart_instance,必须 HITL(Human-in-the-Loop,人在回路确认)
  • 技术栈:Node.js + TypeScript + Vue 3

一张总图

flowchart TD
    ui[&#34;Vue 对话 UI&#34;] -->|&#34;POST + SSE&#34;| session[&#34;会话服务&#34;]
    session --> auth[&#34;鉴权与 RequestContext&#34;]
    auth --> runtime[&#34;Agent Runtime&#34;]
    runtime --> intent[&#34;意图与上下文组装&#34;]
    intent --> gateway[&#34;模型网关&#34;]
    gateway --> model[&#34;LLM Provider&#34;]
    runtime --> toolRuntime[&#34;Tool Runtime&#34;]
    toolRuntime --> businessTools[&#34;业务 API / MCP&#34;]
    runtime --> rag[&#34;RAG 检索&#34;]
    rag --> vectorStore[&#34;向量库 / 全文索引&#34;]
    runtime --> events[&#34;Run 事件与消息持久化&#34;]
    events --> ui
    runtime --> trace[&#34;Trace / Metrics / Eval&#34;]

图上每一块管一件事,别混:

  • 模型负责读懂意思,并给出结构化提案。
  • Runtime 负责推进步骤、卡预算、处理中断。
  • Tool Runtime 负责 schema(参数长什么样)、鉴权、幂等(同一操作做一次和做多次)和真正执行。
  • 会话服务负责身份、Run 生命周期和跟前端的协议。
  • Trace/Eval 负责事后说清「到底哪一跳错了」。

请求怎么进 Runtime

会话 API 入口

前端不应直连模型。它调用自己的会话 API:

POST /api/conversations/conv_123/runs
Authorization: Bearer <user-token>
Content-Type: application/json
Idempotency-Key: 2d7e45b8

{
  "message": "查一下生产环境 order-service 最近的错误日志"
}

RequestContext 从哪来

服务入口中间件从可信登录态拼出上下文。尤其是 tenantId 和 userId,别从模型参数或用户原文里读:

export type RequestContext = { 
  requestId: string   
  traceId: string     
  tenantId: string    
  userId: string      
  roles: string[]     
  scopes: string[]    
  deadlineAt: number  
}

会话服务创建 runId,写入用户消息,然后把 Run 交给 Runtime。HTTP 连接只是订阅进度,不是任务状态本身。

只读时序:从提案到回灌

场景 :用户问「查一下生产环境 order-service 最近的错误日志」。

这是只读链路,不需要 HITL;通常会先定位资源,再查日志,最后给出带证据的回答。按谁干什么读图:

参与方 职责
VueUI 发起 POST,订阅 SSE,渲染步骤与增量文本
SessionAPI 验身份、建 Run、转发事件;不直连模型
AgentRuntime 推进 ReAct:调模型、派工具、收结果、决定是否结束
ModelGateway 统一调用 LLM,返回 tool_calls 或最终文本
ToolRuntime 校验参数、按 RequestContext 鉴权、执行业务 API
Observability 记录 span、版本、Token,供事后归因
sequenceDiagram
    participant U as User
    participant V as VueUI
    participant S as SessionAPI
    participant R as AgentRuntime
    participant M as ModelGateway
    participant T as ToolRuntime
    participant O as Observability

    U->>V: 查询 order-service 错误日志
    V->>S: POST run
    S->>R: start(run, context)
    S-->>V: SSE run_started
    R->>M: messages + tool schemas
    M-->>R: search_resource(args)
    R-->>S: tool_started
    S-->>V: SSE tool_started
    R->>T: invoke(search_resource, args, context)
    T-->>R: exact resource
    R->>M: ToolMessage(result)
    M-->>R: query_logs(args)
    R->>T: invoke(query_logs, args, context)
    T-->>R: structured log summary
    R->>M: ToolMessage(result)
    M-->>R: final answer
    R->>O: spans + metrics + versions
    R-->>S: final message
    S-->>V: SSE text_delta + done

切记:每个 tool_call_id 必须有对应结果。工具失败也要返回结构化错误,否则下一轮模型既无法自我修正,也可能违反模型 API 的消息约束。

关键几跳:

  1. 入口 :前端 POST run,会话服务带着可信 RequestContext 启动 Runtime,同时先推 run_started,让 UI 立刻有反馈。
  2. 第一轮模型 :Runtime 把消息和工具 schema 交给模型。模型提出 search_resource——此时还没查库,只是「提案」。
  3. 工具执行 :Runtime 发 tool_started 给前端,再交给 Tool Runtime 真正调用。身份用上下文里的 tenantId/userId,不信模型参数里的身份字段。
  4. 结果回灌 :工具结果变成 ToolMessage 再进模型。每个 tool_call_id 都必须有一条结果;失败也要结构化回灌,否则下一轮对不齐消息协议。
  5. 第二轮工具 :模型根据资源信息再提 query_logs,同样走 Tool Runtime,拿到结构化日志摘要。
  6. 收尾 :模型给出最终自然语言答案;Runtime 落 Trace,会话服务推 text_delta + done。HTTP 断开不代表 Run 不存在,状态以会话服务为准。

一句话:模型只提案、Tool Runtime 才执行;前端只订事件、别自己猜「是不是在调工具」;写操作别走这条「连续自动跑」的路,要进下一节的 HITL。

事件、写操作 HITL 与落库

前端、落库和观测最好围着同一组业务事件转。事件类型可以先定成下面这一组:

export type RunEvent = 
  | {
      type: 'run_started'    
      runId: string          
      sequence: number       
    }
  | {
      type: 'text_delta'     
      runId: string          
      sequence: number       
      delta: string          
    }
  | {
      type: 'tool_started'   
      runId: string          
      sequence: number       
      callId: string         
      tool: string           
    }
  | {
      type: 'tool_finished'  
      runId: string          
      sequence: number       
      callId: string         
      status: 'success' | 'error' 
      summary: string        
    }
  | {
      type: 'interrupt'      
      runId: string          
      sequence: number       
      interruptId: string    
      payload: unknown       
    }
  | {
      type: 'done'           
      runId: string          
      sequence: number       
      messageId: string      
    }
  | {
      type: 'error'          
      runId: string          
      sequence: number       
      code: string           
    }

sequence 用来去重和以后补发;第一版就算还没做事件重放,字段也先留着。别让前端靠解析自然语言去猜「现在是不是在调工具」。

写操作为什么必须换一条路

如果用户说:“重启这个异常实例”。模型只能提出动作,不能自己给自己开权限:

stateDiagram-v2
    [*] --> Running
    Running --> WaitingHuman: restart_instance proposed
    WaitingHuman --> Running: approved + reauthorized
    WaitingHuman --> Cancelled: rejected or expired
    Running --> Completed
    Running --> Failed

恢复时至少重做四件事:

  • 校验 interruptId 仍 pending;
  • 按当前 RequestContext 重新鉴权;
  • 重新读取资源,防止确认期间状态变化;
  • 用幂等键避免双击和网络重试造成重复执行。

具体实现见后面几篇;多轮、SSE、HITL 和工具 Runtime 会分开写。

Run 最少要存什么

SSE 可能断,Run 还在跑。所以库里至少要有一张「状态小票」RunRecord:随时能查这笔请求卡在哪、用了哪版 Prompt/模型。它不是聊天全文,也不是工具吐出来的大结果。

type RunRecord = { 
  id: string                 
  conversationId: string     
  tenantId: string           
  status:                   
    | 'queued'               
    | 'running'              
    | 'waiting_human'        
    | 'succeeded'            
    | 'failed'               
    | 'cancelled'            
  lastSequence: number       
  promptVersion: string      
  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 找第一个错误点:

{
  traceId: 'tr_01', 
  intent: { 
    type: 'query_logs', 
    environment: 'production' 
  },
  toolCalls: [ 
    {
      name: 'query_logs', 
      args: { 
        service: 'order-service', 
        environment: 'test' 
      }
    }
  ]
}

意图对了、工具参数错了,就修选工具/填参的 Prompt 或结构化约束;别先去改数据库查询。

交付顺序与业务验收

交付顺序没有行业标准。下面只是风险从低到高的建议,不是固定周数:

  1. 会话落库 + 文本 SSE。
  2. 一个只读工具 + ToolMessage 错误回灌。
  3. RAG 权限过滤 + 引用。
  4. 写工具 + HITL + 幂等审计。
  5. Eval 发布检查 + Trace 大盘。
  6. 模型降级、预算、灰度和回滚。

业务侧也不能省。一次完整上线至少能说清:

需求澄清 → 成功指标 → 只读原型 → 写操作与 HITL → 上线 → 采用率 / 用户反馈 → 回滚与复盘

成功指标先写业务结果(例如「值班能在工作台查完日志并点开引用」),再写模型指标。没有采用率和回滚记录,只能证明系统能跑,不能证明有人在用。

小结

这篇先钉住三件事:

  1. 模型提案,Tool Runtime 执行。
  2. 前端订事件,不猜状态。
  3. 写操作必须 HITL + 重鉴权。

文中的 OpsPilot、ID 和 Trace 都是可复现的示例。下一篇继续:《Agent 意图识别:从语义理解到稳定路由》。

希望这篇文章对你有所帮助、有所借鉴。