不搭 demo,直接解剖一个生产级 Agent 平台:loser-agent 全链路地图
2026/9/11 20:13:43 网站建设 项目流程

源码地址:后端地址 前端地址

市面上 Agent 开发教程不少,十行代码跑通ReActAgent.builder().build()的 demo 满天飞。但 demo 不会告诉你:真实生产环境里,一个 Agent 平台到底由什么组成、一次聊天请求从发出去到看到回复中间穿了多少层。

这门课不搭 demo。我们手上有一个真的在跑的生产级 Agent 平台——loser-agent,基于阿里开源的 AgentScope Java 2 构建,带灰度发布、人工审批、审计、评测、知识库、定时任务,是套完整的平台代码。整门课只做一件事:跟着一次聊天请求,走完整个平台的旅程

这一篇是全课的导航图。在钻进任何一行源码之前,先让你知道整条旅程长什么样、每一站叫什么、后续每一篇都在旅程的哪个位置。

一张图看懂全课路线

一次聊天请求的旅程,本质上就七个站点:

用户发消息 → 入口鉴权 → 灰度路由 → Agent 装配 → 模型路由 → 工具与执行 → 持久化

每个站点的存在都不是偶然的——入口要处理流式协议,灰度层决定这个请求命中哪个版本的 Agent,装配决定这个 Agent 会什么、能碰什么,持久化让用户刷新页面后对话还在。后续每一篇都是这条线上某个站点的展开。

篇章集数旅程上的位置
地图(开篇)01-02全景 + 点火
主线链路03-11入口→持久化的七站逐一展开
插页:事故复盘12(可选 12b)独立复盘集
能力扩展13-17HITL / Skills / 多 Agent / RAG
工程化支撑18-22安全 / 审计 / 评测 / 调度 / 总复盘

整门课 22 集(加 1 集可选插页),约 3 个月更完。适合 Java 后端转 Agent 开发的工程师读——你熟 Spring Boot / MyBatis,不需要预设 Agent 框架知识。读完整套,你能独立看懂 loser-agent 全部主链路源码,并具备用 HarnessAgent 装配真实业务 Agent 的能力。

为什么不直接用框架现成的入口

AgentScope 官方提供了agentscope-spring-boot-starter,全部代码就 4 个类,自动装配默认的 ReActAgent/Toolkit/Memory bean。BOM 里还管理着一个 agui 模块。看起来拿来用就行。

但 loser-agent 的AguiChatController类头 Javadoc 写得很直白:

AG-UI Chat Controller:v3 自建,替代 v2 假设的 AguiMvcController。

为什么不直接用现成的?打开AguiChatController.chat()方法,从第 104 行到 286 行,约 165 行,没拆 helper 类——一次请求的全部平台级关注点同屏可见。这一个方法里编织了:归属鉴权、灰度路由、HITL 恢复与自动拒绝、多模态附件、审计上下文、Plan 同步、熔断上报、记忆抽取——8 个平台级关注点

这些框架不可能替你决定:它不知道你的灰度策略、你的 RBAC 模型、你的审计口径。于是 loser-agent 得到一个全课反复出现的分界原则:框架交钥匙管 Agent 运行时语义(ReAct 循环、中间件、状态 SPI、事件模型),平台自建管请求生命周期里框架之外的其余一切(协议适配、灰度路由、装配、持久化、审计)。第 22 集会用一张完整决策清单回收这个主题。

一次请求的调用顺序

这一篇只看调用顺序,不进任何被调类的内部——把chat()方法当一张地图来读。

第一段:归属鉴权。第 120 行:

agentConfigService.assertCanAccess(agentCode,empId);

V2.20 加入的运行时归属鉴权——私有(PRIVATE)Agent 只有 owner 能发起会话,无权抛SecurityException,由@ExceptionHandler统一转成 403。这是请求的第一道门。

第二段:灰度路由。第 123-138 行:

StringenvName=envResolver.resolve(request);intversion=grayRoutingService.resolveVersion(agentCode,empId,envName);if(version>0){StringversionedKey=AgentVariant.registryKeyWithVersion(agentCode,version);factory=registry.find(versionedKey);...}else{factory=registry.find(agentCode);// 未发布 / fallback → DRAFT 路径}

两步:RequestEnvResolver从请求里识别环境(gray/stable),GrayRoutingService按用户和 Agent 算出他命中的发布版本——0 走 stable,大于 0 命中灰度。拿到版本号后,用「带版本的 key」去AgentFactoryRegistry找工厂;找不到触发registry.refresh()再找;仍没有回落 DRAFT。

这里冒出三种 Agent 变体:agentCode(草稿)、agentCode_published(稳定发布)、agentCode_published_v<n>(灰度版本),key 拼装规则收在AgentVariant里。灰度与发布流程后面第 04 集解剖。

第三段:建立 SSE 通道。第 144-153 行:

SseEmitteremitter=newSseEmitter(3600_000L);response.setHeader("X-Accel-Buffering","no");response.setHeader("Cache-Control","no-cache");

超时给足 1 小时;X-Accel-Buffering: no是给 Nginx 的指令——关掉反向代理缓冲,否则 SSE 会被攒成一坨「假流式」。随后注册onCompletion/onTimeout/onError三个回调做连接统计。事件流信封协议第 03 集专讲。

第四段:会话落库。第 156 行调upsertSession(417-443 行):按 threadId 查,没有则新建(标题取首条用户消息截断 50 字),有则校验归属——threadId 属于其他用户直接 403,防会话劫持。查与写包在transactionTemplate事务里,中途失败不留脏行。第 09 集展开。

第五段:装配 Agent。第 159-166 行,三行完成「从数据库配置到可运行 Agent」:

List<String>roleCodes=resolveRoleCodes(request);RuntimeContextrc=ctxBuilder.build(threadId,empId,ssoToken,null,agentCode,roleCodes);Agentagent=factory.createAgent(rc);
  • resolveRoleCodes()从 AuthFilter 注入的 request attribute 取角色编码,auth 关闭时为空列表
  • ctxBuilder.build()把 threadId、用户身份、SSO 令牌、角色打包进框架的RuntimeContext,贯穿本次请求每一轮推理
  • factory.createAgent(rc)是装配主战场,内部是一条HarnessAgent.builder()

装配里值得现在就记住一个插件点——BaseFinanceAgentFactory第 141 行:

.stateStore(stateStore)// 平台的 DbAgentStateStore 插进框架的 AgentStateStore SPI

框架定义AgentStateStore接口,平台提供 MySQL 实现——从此 Agent 每一轮状态(消息、工具调用、暂停点)都落进ac_agent_*表。十个 Builder 开关怎么把 DB 配置翻译成 Agent,第 05 集;模型怎么路由、挂了怎么办,第 06 集。

第六段:请求语义补全。第 169-227 行,执行前还有一段预处理事务:

  • HITL 状态stateStore.findAskingToolCalls()查上一轮有没有工具暂停在「等人工审批」。带了confirmResults就恢复执行;没带就自动按拒绝处理——否则线程永久卡死。第 13 集。
  • 多模态附件:文本附件拼TextBlock,图片转ImageBlock(URLSource),与用户文字组装成框架的Msg
  • runId + traceId:两个 UUID,前者关联本次运行的全部事件,后者贯穿所有 LLM 轮次与工具调用——评测和调用链下钻全靠它们。
  • Plan 监听器 + 工具审计器:通过事件流doOnNext驱动,不挡主流程。第 21 集 / 第 19 集。

第七段:执行与事件流。第 228-284 行,全链路最核心的三次变换:

Flux<AgentEvent>rawFlux=((HarnessAgent)agent).streamEvents(userMsg,rc);rawFlux=rawFlux.doOnNext(toolAuditor::dispatch);Flux<SseEnvelope>adapted=SseFrontendAdapter.adapt(rawFlux);adapted.contextWrite(ctx->ctx.put(AgentAuditContext.CONTEXT_KEY,...)).subscribe(env->{...sendSse(emitter,env);},err->{...reportStreamFailure(agentCode);emitter.completeWithError(err);},()->{...extractMemory(empId,threadId,agentCode);emitter.complete();});
  1. 执行streamEvents(userMsg, rc)启动框架的 ReAct 循环(思考→调工具→观察→再思考),返回Flux<AgentEvent>——框架把执行过程统一建模为事件流。
  2. 适配SseFrontendAdapter.adapt()把框架事件映射为平台信封SseEnvelope,前端才看得懂。
  3. 订阅:三个回调是三条出口——onNext 逐条推前端;onError 区分客户端断开与真实模型故障(只有后者上报熔断器,连续失败达阈值自动切备用模型,第 06 集);onComplete 审计落库后extractMemory()做记忆抽取——记忆发生在会话完整结束之后,第 11 集。

最后看一个数量级问题:从 HTTP 接收到subscribe()提交,chat()没有任何阻塞调用;SseEmitter一返回,Servlet 请求线程即释放,之后的事件推送都在 Reactor 调度线程上完成。一万路并发会话,就是一万条互不阻塞的事件流订阅。

AgentScope 三层 × 平台四层

读完上面的调用顺序,你大概能感觉到平台代码和框架代码是两种身份在交织。这其实是两套分层体系的叠加。

框架侧三层(AgentScope Java 2.0.1),对照mvn dependency:tree的真实输出:

artifact职责
coreagentscope-core零框架依赖的内核:ReActAgent、Msg/Model/Toolkit、AgentStateStore SPI、事件模型
harnessagentscope-harness编排壳:HarnessAgent + workspace/filesystem/sandbox/subagent/skill/plan-mode/MCP
extensionsagentscope-extensions-*并行生态模块族:每家模型厂商一个包(OpenAI/DashScope/Ollama/Anthropic/Gemini)

平台侧四层是经典 Maven 分层:loser-common(工具与公共配置)→loser-agent-repository(持久层,37 个Ac实体对应 37 张ac_*表)→loser-agent-app(应用与运行时,28 个 Controller + 装配 + 各子系统)→loser-agent-web(Vue 3 前端,本课不设专集,仅第 19 集联动一次)。

平台代码触达框架的方式可归为四类:装配入口HarnessAgent.builder(),第 05 集);实现 SPIAgentStateStoreAgentSkillRepositoryModel,框架定义接口、平台给实现);消费数据类型Msg/ContentBlock/ToolUseBlock,全仓 85 个文件 importio.agentscope.*);接模型厂商(extensions 的 ChatModel 实现,经SdkChatModelBuilder接入,第 06 集)。触达面不小,但每一处都有明确身份——这正是第 22 集盘点扩展点全景时的伏笔。

亲眼看见三层

不用搭环境,用 Maven 验证上面的分层故事(本机实测输出节选):

mvn-plloser-modules/loser-agent-app dependency:tree"-Dincludes=io.agentscope"
[INFO] com.loser:loser-agent-app:jar:0.0.1-loser-agent-SNAPSHOT [INFO] +- io.agentscope:agentscope-core:jar:2.0.1:compile [INFO] +- io.agentscope:agentscope-harness:jar:2.0.1:compile [INFO] +- io.agentscope:agentscope-extensions-model-dashscope:jar:2.0.1:compile [INFO] +- io.agentscope:agentscope-extensions-model-openai:jar:2.0.1:compile [INFO] +- io.agentscope:agentscope-extensions-model-gemini:jar:2.0.1:compile [INFO] +- io.agentscope:agentscope-extensions-model-anthropic:jar:2.0.1:compile [INFO] +- io.agentscope:agentscope-extensions-model-ollama:jar:2.0.1:compile [INFO] +- io.agentscope:agentscope-extensions-skill-mysql-repository:jar:2.0.1:compile [INFO] +- io.agentscope:agentscope:jar:2.0.1:compile [INFO] \- io.agentscope:agentscope-spring-boot-starter:jar:2.0.1:compile

两个观察:① core 与 harness 是两个独立 artifact,extensions 是一个模块族(一家模型厂商一个包)——三层各就各位;② 倒数第二个io.agentscope:agentscope是 shaded 聚合包(内含 1300+ 类),与前面的分模块 artifact 在 classpath 上同 FQCN 双份——这是依赖演化的历史痕迹,也提醒你「All-in-One 与分模块混用」是框架使用的一个反模式,后面讲模型接入时会再碰它。

顺手在 IDE 里打开AguiChatController.chat(),用 Call Hierarchy 从chat()一路点到DbAgentStateStore的实现方法——你会穿过框架边界再绕回平台代码,正好亲脚走一遍上面那条调用顺序的往返。

小结

记住三件事:

  1. 主线:一次聊天请求 = 鉴权入口 → 灰度路由 → 会话落库 → 装配 → 请求补全 → ReAct 执行 → 事件流适配 → 持久化/记忆/熔断。全课 22 集都是这条线的站点或支线。
  2. 两层地图:框架三层(core 内核 / harness 编排壳 / extensions 生态)× 平台四层(web / app / repository / common);平台以「装配入口、实现 SPI、消费数据类型、接模型厂商」四种方式触达框架。
  3. 分界原则:运行时语义交给框架,请求生命周期其余一切平台自建。

下一篇我们把环境搭起来:docker 拉起 MySQL,Flyway 40 个迁移建出全部ac_*表——以及LOSER_FLYWAY_ENABLED默认关闭、auth mode 默认 local(admin/admin123)、master-key 空了启动崩这三个「第一次跑必踩」的坑。建第一个 Agent,发第一条消息,用肉眼看到 SSE 事件流——到那时,这张地图就变成你亲手发出去的请求。

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

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

立即咨询