基于 Bubbletea v2 与 Google ADK Go 的 Agent TUI实现总结

Pinkman 是一个 A 股投研 agent 项目:底层用 Google ADK Go(google.golang.org/adk/v2)做 agent 运行时,前端是一个 Bubbletea v2 写的多会话 TUI,模型侧接的是 DeepSeek(OpenAI 兼容协议)而非 Gemini。这篇文章总结它在 TUI 实现和 ADK 集成两侧的设计细节与踩坑。

一、整体架构

进程内直连,没有 HTTP 中间层:

cmd/pinkman-tui/main.go
  → appconfig.NewLocalClient()        // 组装 *agentapi.Service
  → tui.Model.runtime.NewRunner(route, version)   // *runner.Runner
  → runner.Run(ctx, userID, sessionID, content,
      RunConfig{StreamingMode: SSE})  // 返回 iter.Seq2[*session.Event, error]

internal/agentapi/service.go 刻意"不包装 runner.Run、不翻译 ADK 事件",TUI 直接消费原生 session.Event(Partial 流式增量、UsageMetadata、RequestedInput、Output、StateDelta)。同一份 runner 还被 internal/frontend/noninteractive.go--json NDJSON / --plain 纯文本)和 internal/httpapi/server.go(官方 server/adkrest REST 适配器)复用。

二、TUI 实现细节

框架选型

Charm 全家桶 v2(新的 charm.land 模块路径):

  • charm.land/bubbletea/v2 v2.0.8 — 主框架,Elm 架构
  • charm.land/bubbles/v2 — 只用 textarea(输入框)和 viewport(滚动区),其余面板全部 lipgloss 自绘
  • charm.land/lipgloss/v2 + charm.land/glamour/v2 + yuin/goldmark — 样式与混合 Markdown 渲染
  • charmbracelet/x/ansirivo/uniseg — ANSI 感知截断、UAX #14 断行、grapheme 宽度

bubbletea v2 与 v1 的一个显著差异:View() 返回 tea.View 结构体,AltScreen、鼠标模式、窗口标题、光标位置都在 View 里声明,而不是发 tea.EnterAltScreen 命令。

单 Model + 多会话并发

internal/tui/model.goModel 是单一根 Model,内含 viewport + composer 两个子组件,以及一组按 sessionID 索引的 maptranscripts / usages / statuses / active / pending / cancels / follow / unread / hydrated。也就是多个会话可以并发跑,每个会话有独立的 transcript、状态机、cancel func 和滚动跟随状态;非当前会话只累加 unread 计数,不重渲染。

goroutine 与 Elm 循环之间用自建事件通道桥接:

events chan tea.Msg  // 缓冲 64
func (m *Model) waitEvent() tea.Cmd { return func() tea.Msg { return <-m.events } }

Init() 启动一个 waitEvent,每处理一条事件消息再注册下一个——始终只有一个 waiter,防止相邻批次互相超越(有专门测试 TestSubmitKeepsSingleEventWaiter)。异步结果以自定义消息类型回流:eventBatchMsgrunErrorMsgrunStreamClosedMsgsessionsLoadedMsg 等。

事件泵:30fps 批处理

每次提交起一个 goroutine 消费 ADK 迭代器:内层把事件推入 runEvents channel,外层用 30fps ticker + 256 条上限做批处理(HITL 请求立即 flush),批量消息经 m.events 进入 Elm 循环。这避免了逐 token 触发整帧重绘。

结构化 transcript

transcript 不是一个大字符串,而是 []transcriptEntry,7 种 entryKind(user/assistant/thinking/tool/approval/error/system)。结构化带来的能力:

  • AppendStream:流式增量合并进同类 cell;
  • UpsertTool:工具调用状态原地更新;
  • FinalizeAssistantMarkdown:最终报告一次性替换流式碎片;
  • thinking 默认只渲染最后 3 行的"移动窗口",Ctrl+T 展开。

Markdown 渲染:glamour + goldmark AST 混合

glamour 负责标题/引用/代码/表格,但列表从 goldmark AST 自行布局,以获得正确的悬挂缩进;行内样式用 lipgloss 手绘。换行用 uniseg 的 UAX #14 断行机会而非简单按词断——1/41364.8、ISO 日期这类 token 会作为整体移动,全程用 ansi.Cut/ansi.Strip 保证 ANSI 序列完整。

值得抄的细节

  • 终端注入防护:所有模型/工具输出进渲染器前经 SanitizeTerminalText 剥离 CSI/OSC 序列和控制字符,防止模型输出操纵终端(比如 OSC 52 写剪贴板)。
  • IME 光标:composer 用真实终端光标而非虚拟光标——流式重绘 transcript 时输入法候选窗锚定才正确,光标 Y 坐标在 View 里手动换算。
  • 主题自适应tea.RequestBackgroundColor 探测终端背景,切明暗两套 palette;--no-color/--ascii 走 plain 模式。
  • 鼠标选择:跨行拖拽选择 + grapheme 感知列定位,松开时 tea.SetClipboard 复制,选区反色高亮。
  • 键盘:Ctrl+C 运行中=cancel(每会话独立 CancelFunc)、空闲=退出;Alt+↑/↓ 提示历史;Tab 补全 13 个斜杠命令;PgUp/PgDn 脱离跟随后在 usage 行显示 “paused at N% · M new”,回到底部自动恢复跟随,且重渲染不抢滚动位置。

三、ADK Go 集成细节

定位:ADK 是唯一运行时

ADR 0001 把架构固化为:ADK 是唯一 agent 运行时,workflowagent 图是唯一业务工作流形态——不自实现 agent loop / Runner / DAG 执行器 / Session 服务。还有自动化架构守卫(internal/architecture/guard_test.go)禁止 import adk/v2/internal/ 和传统的 sequential/parallel/loop agent。

Agent 树

pinkman_root_router (LlmAgent,只做 transfer_to_agent)
├── pinkman/industry-theme/v1      (workflowagent)
├── pinkman/event-impact/v1        (workflowagent)
├── pinkman/company-judgement/v1   (workflowagent)
├── pinkman/earnings/v1            (workflowagent)
└── pinkman_general_agent          (LlmAgent, ModeChat)

每个研究工作流用 workflowagent.New + workflow.NewEdgeBuilder 建图:

route_plan → skeleton_map → thesis → fan-out(N 个数据源工具节点)
  → JoinNode(evidence_join) → evidence_ingestion → acceptance_gate
  → compliance_review → fan-out(bundle_passthrough, analysis_agent)
  → synthesis_join → pm_brief

四条路由共享同一个 build() 骨架,只是数据源集合不同(表驱动)。工具全部用 functiontool.New[In, Out] 泛型定义,数据源节点再经 workflow.NewToolNodeTyped 变成图节点,产物落 ADK artifact 并做 SHA-256 校验。

模型接入:把 DeepSeek 桥接成 model.LLM

没有用 ADK 自带的 Gemini model,而是自研了一层:

  1. internal/model/provider.go 定义 LLMBackend 抽象与统一流事件枚举(thinking_delta/text_delta/tool_call_*/usage/response_done);
  2. provider 实现有 deepseek/kimi/openai/openaichat,deepseek 走 OpenAI 兼容协议;
  3. internal/model/adkbridge/bridge.goBridge 实现 ADK 的 model.LLM 接口,把 LLMRequest(genai.Content)翻译成自家 Request,再把流式响应翻译回 LLMResponse(流式时逐 delta 产出 Partial: true,思考标 Thought: true,结束产出聚合响应);
  4. backend 外层依次包 retry.Backend(3 次重试)→ CircuitBreaker(5 次熔断/30s 冷却)→ BudgetBackend(20 万 token 总额度)。

会话与运行

  • 官方 runner.New(...),Session 持久化用官方 session/database.NewSessionService + glebarez SQLite + AutoMigrate;Artifact 用 InMemory,Memory 显式禁用。
  • 流式用 agent.StreamingModeSSE
  • HITLcompliance_review 节点用 workflow.ResumeOrRequestInput + session.RequestInput 发审批中断;前端检测 event.RequestedInput 弹审批 overlay,恢复时再次 runner.Run,把用户回复包装成带 interruptIDFunctionResponse part。
  • 上下文压缩contextwindow.Manager.Callback() 是一个 BeforeModelCallback,超阈值时用 provider 自身摘要压缩历史,checkpoint 存 artifact,切分点保证不拆开 function call 与其 response。
  • 会话标题BeforeAgentCallback 从首条用户消息生成标题,经 StateDelta["pinkman.session_title"] 事件推给前端,零轮询。

踩坑记录(这部分最值钱)

  • SQLite 单写者:官方 database.SessionService 并发开事务会 SQLITE_BUSY,解法是 WAL + busy_timeout(5000) DSN,再套一个读写锁 shim(serializedSessionService)。
  • GORM 日志污染 stdout:TUI/NDJSON 都占 stdout,必须用 gormlogger.Discard
  • Schema 大小写:ADK 工具 schema 序列化为 "OBJECT"/"STRING",OpenAI 兼容端要求小写 JSON Schema,bridge 里递归归一化。
  • 并行 FunctionResponse 拆分:ADK 把多个 FunctionResponse 合在一条 user Content,OpenAI 协议要求每个 tool_call_id 一条 tool 消息且紧跟 assistant tool_call 消息,contentToMessages 专门处理。
  • ADK v2.1.0 图节点输入注入 bug:wrapped Session 下 branch 调度时合成事件可能不在 request 里,用 ctx.UserContent() 兜底并把 envelope 塞回 request。
  • Agent 名全局唯一 vs join key 稳定:四个工作流的 analysis agent 需要路由级唯一名,但 join key 要保持 analysis_agent,用 nodeAlias(BaseNode 换名 + 委托执行)解决。
  • 版本化 RunState:workflow 名含 pinkman/<route>/<version>,因为 ADK v2.1.0 不持久化图指纹,版本变更后旧暂停状态拒绝 resume。
  • Prompt 注入防护:artifact 正文以 <UNTRUSTED_SOURCE> 块拼入模型请求,带 SHA-256 校验和字节预算。

四、小结

两个可以带走的决策:

  1. 不翻译、不包装:前端直接消费 ADK 原生 session.Event,换来的是 Partial/StateDelta/RequestedInput 等能力零损耗,代价是前端要理解 ADK 的事件语义——对单团队项目是值得的。
  2. 桥接而非替换:模型侧用一层 bridge 把任意 OpenAI 兼容模型适配进 model.LLM,配合重试/熔断/预算的中间件链,ADK 的 runner、session、workflowagent 全部保留官方实现,升级成本最低。