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/ansi、rivo/uniseg— ANSI 感知截断、UAX #14 断行、grapheme 宽度
bubbletea v2 与 v1 的一个显著差异:View() 返回 tea.View 结构体,AltScreen、鼠标模式、窗口标题、光标位置都在 View 里声明,而不是发 tea.EnterAltScreen 命令。
单 Model + 多会话并发
internal/tui/model.go 的 Model 是单一根 Model,内含 viewport + composer 两个子组件,以及一组按 sessionID 索引的 map:transcripts / 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)。异步结果以自定义消息类型回流:eventBatchMsg、runErrorMsg、runStreamClosedMsg、sessionsLoadedMsg 等。
事件泵: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/4、1364.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,而是自研了一层:
internal/model/provider.go定义LLMBackend抽象与统一流事件枚举(thinking_delta/text_delta/tool_call_*/usage/response_done);- provider 实现有 deepseek/kimi/openai/openaichat,deepseek 走 OpenAI 兼容协议;
internal/model/adkbridge/bridge.go的Bridge实现 ADK 的model.LLM接口,把LLMRequest(genai.Content)翻译成自家Request,再把流式响应翻译回LLMResponse(流式时逐 delta 产出Partial: true,思考标Thought: true,结束产出聚合响应);- backend 外层依次包
retry.Backend(3 次重试)→CircuitBreaker(5 次熔断/30s 冷却)→BudgetBackend(20 万 token 总额度)。
会话与运行
- 官方
runner.New(...),Session 持久化用官方session/database.NewSessionService+ glebarez SQLite + AutoMigrate;Artifact 用 InMemory,Memory 显式禁用。 - 流式用
agent.StreamingModeSSE。 - HITL:
compliance_review节点用workflow.ResumeOrRequestInput+session.RequestInput发审批中断;前端检测event.RequestedInput弹审批 overlay,恢复时再次runner.Run,把用户回复包装成带interruptID的FunctionResponsepart。 - 上下文压缩:
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 校验和字节预算。
四、小结
两个可以带走的决策:
- 不翻译、不包装:前端直接消费 ADK 原生
session.Event,换来的是 Partial/StateDelta/RequestedInput 等能力零损耗,代价是前端要理解 ADK 的事件语义——对单团队项目是值得的。 - 桥接而非替换:模型侧用一层 bridge 把任意 OpenAI 兼容模型适配进
model.LLM,配合重试/熔断/预算的中间件链,ADK 的 runner、session、workflowagent 全部保留官方实现,升级成本最低。