Flutter AI应用可观测性实践:用OpenTelemetry打造Dartastic监控方案

Flutter AI应用可观测性实践:用OpenTelemetry打造Dartastic监控方案 最近在给团队做AI能力进 Flutter 应用的落地一个最头疼的问题就是用户说“AI 回答很卡”但到底卡在网络、卡在模型推理、还是卡在端上渲染传统的页面监控只能看到 HTTP 接口耗时AI 的流式输出是一串不断到达的 partial 事件普通监控根本抓不住。后来我把 OpenTelemetry 的整套思路搬到 Flutter 端封装了一套叫 Dartastic 的监控方案才算是把这条链路看清楚。这篇文章不打算做概念科普直接讲我踩过的坑和最终跑通的方案。结构是先拆思路再讲工具选型然后给完整接入步骤重点说 AI 场景下的埋点模型最后是一份排查清单。如果你也在 Flutter 里接大模型、做 Agent、或者只是想把客户端可观测性补起来这篇文章应该能帮你少走不少弯路。1. AI 时代的 Flutter为什么必须换一种监控方式1.1 传统监控模型的三个盲区先说个场景。一个 Flutter 应用里接了 LLM 对话能力用户发起提问后客户端把 prompt 送到后端后端再转发给大模型服务模型输出以 SSE 流式返回客户端边收边渲染。整个过程中传统监控能看到的是哪一段最多是那条POST /v1/chat/completions的 HTTP 请求耗时。但实际体验问题往往出在别处。第一模型服务本身可能很慢但网关层有超时重试客户端等到的可能是重试后的结果体验是“转了五秒才出第一段”传统监控看到的却是 3 秒成功第二客户端拿到流式数据后要解析、要按 Markdown 分段、要渲染到 TextField这部分耗时完全在端上任何服务端监控都看不到第三现在很多 AI 功能已经不是说“一次请求一次回复”这么简单了Agent 场景下工具调用、上下文拼接、多轮记忆检索都是链路缺一环整个体验都会崩。这三个盲区恰好都是传统监控模型的盲区。传统监控以“请求-响应”为最小单位但 AI 应用的最小单位是“一系列关联的事件”这些事件分布在客户端、网关、模型服务、工具插件上时间跨度可能从几百毫秒到几分钟。没有一条贯穿所有环节的时间轴排障就只能靠猜。1.2 你需要的是可观测性不是埋点日志很多人一听说监控第一反应是“多打日志”。但这个思路在 AI 场景下会崩盘LLM 的 prompt 可能几千 token打完整内容日志量直接爆掉不打完整内容又没法定位是不是 prompt 工程的问题。而且日志是离散的同一轮对话的日志散落在 App 端、网关端、模型服务端要靠 requestId 一层一层串遇到跨团队的系统基本串不动。可观测性做的事情不一样它把“链路”作为第一公民。一次用户提问被抽象成一条 tracetrace 里面有若干个 span每个 span 描述一个具体的操作客户端发起、网关转发、模型推理、工具调用、流式接收、UI 渲染。每个 span 都有开始时间、结束时间、状态和结构化属性。有了这条链路你可以精确回答“这次对话为什么慢了 3 秒”——大概率是模型推理占了 2.8 秒客户端渲染只占 200 毫秒。这里我特别想强调一句监控的目的是回答“为什么”不是回答“发生了什么”。日志回答“发生了什么”指标回答“总量是多少”只有 trace 能回答“为什么”。AI 应用的问题天然跨越多个组件所以 trace 不是可选项是必需品。1.3 终端侧的可观测性为什么一直被忽略还有一个现实问题服务端的可观测性已经相当成熟但客户端——尤其是 App 端——一直是可观测性的死角。原因也很简单服务端是你部署的你能在各种语言里塞 SDKApp 端是别人的手机你怎么知道用户那台手机上发生了什么网络抖动、弱网、内存不足、被系统杀进程这些全部发生在你看不到的地方。在 Flutter 里这个问题更明显。Flutter 的异步模型基于 Dart 的 isolate大多数业务代码跑在 UI isolate 里可一旦用到 compute、Isolate.run 或者后台 isolate你会发现一个坑Dart 默认并不帮你传递跨 isolate 的上下文。服务端做 trace 的时候有一个无处不在的 context到 Flutter 里没有这个东西你必须自己维护。这也是我为什么要把埋点封装成 Dartastic 这种工具层的原因——没有统一封装每个开发者各自为政trace 一定断。2. Dartastic 与 OpenTelemetry一次正确但需要付出的选择2.1 为什么不用自研打点先用一句话说清楚 Dartastic 是什么它不是一个官方 SDK而是我在项目里做的一套基于 OpenTelemetry 规范的 Dart/Flutter 监控封装统一处理 Tracer 初始化、Context 传递、Span 生命周期、Exporter 上报以及 AI 场景的语义约定。这套方案本身没有发明新协议全部跑在 OTel 的标准上。所以后端的同事不用学新东西Grafana 里加个数据源就能看。为什么不自研我见过太多团队自己写打点方案定义了一个TrackInfo类塞了 method、params、duration 三个字段上报到哪里就看谁有空谁写。一开始很轻后来字段越加越多链路串不起来日志比例失调最后沦为废代码。自研打点最大的问题不是写不出来而是你只会写你的业务需要的打点一旦出现新场景比如接了一个新的 AI 工具就没有可扩展的语义定义了。OTel 的语义约定semantic conventions解决的就是这个问题。比如 HTTP 请求的 span 该叫什么、状态码用什么字段、错误信息放到哪个属性里都有成熟约定。接进来之后你不需要重新发明轮子后续接任何开源组件都有现成的埋点。2.2 Dartastic 的组成与核心抽象我做这套封装的时候核心抽象只有四个Tracer、Span、Context 和 Exporter。Tracer 是创建 Span 的入口在 Flutter 里通常是一个单例初始化时绑定一个 Exporter。Span 是链路里的一个节点有名字、开始时间、结束时间、属性和状态。Context 是贯穿链路的状态对象里面装着 traceId、spanId 和 baggage它要能跨异步、跨 isolate 传递。Exporter 负责把 Span 序列化后送达后端常见的有 OTLP/HTTP 和 OTLP/gRPC 两种。这四样东西是标准 OTel 都有的Dartastic 做的事情主要是让它们好用一是自动管理 Context在入口处创建 Span在出口处自动结束二是把 Flutter 生命周期、网络库、异常捕获统一接入三是给 AI 调用场景预设好 span 模板避免每个人埋点埋得五花八门。2.3 选 OTel 还是商业 APM我不排除商业 APM如果你团队没有可观测性基础设施买现成的确实省事。但有几个问题在 Flutter/AI 场景下很现实第一商业 APM 的客户端 SDK 通常对 Flutter 支持滞后Dart 不是主流语言很多产品只提供 iOS/Android 原生 SDK第二AI 调用的语义约定还在快速演进商业产品未必跟得上第三数据出境和隐私合规问题你很难控制第三方 SDK 把数据送到哪里。而 OTel 是开源标准数据落在自己的后端比如 VictoriaMetrics、Tempo、Loki、Grafana 全家桶自己掌握样本率、脱敏规则、存储周期。我最终选择这套方案核心原因是可控。代价是初期工作量多一些要自己搭后端、自己封装 SDK、自己配 Grafana。但这是一次性投入做完之后整个团队都受益而且是标准的、可持续的。3. 从零落地 Dartastic初始化、埋点与跨 isolate 传递3.1 初始化 TracerProvider 与 OTLP Exporter第一步是在 App 启动时完成 TracerProvider 的初始化。我强烈建议放在main()里异步初始化不要阻塞首帧渲染但也不要拖到用户点完第一个按钮之后。具体做法是定义一个initDartastic()函数用runApp之前先 await 一下。首先在 pubspec.yaml 里引入依赖dependencies: opentelemetry: ^0.4.0 opentelemetry_sdk: ^0.4.0 opentelemetry_otlp: ^0.4.0然后初始化Futurevoid initDartastic() async { final exporter OtlpHttpSpanExporter( uri: Uri.parse(https://otel.example.com/v1/traces), headers: { x-api-key: your-ingest-token, }, ); final provider TracerProvider( spanProcessors: [ // 开发环境用 SimpleSpanProcessor方便立即看到数据 // 生产环境换成 BatchSpanProcessor合并网络请求降低开销 BatchSpanProcessor(exporter), ], ); await provider.initialize(); Tracer.setGlobalTracerProvider(provider); // 给每个 trace 加上全局资源信息 provider.addResource( Resource( attributes: { service.name: your-flutter-app, service.version: 1.2.3, device.model: await DeviceInfoPlugin().deviceModel, device.os: Platform.operatingSystem, }, ), ); }这里有几个重点。BatchSpanProcessor会缓存一批 span 再一起上报避免每条请求都发一次网络极大降低对 UI 线程的影响。但代价是数据会有最多几秒的延迟排查问题时需要等待。所以我在 debug 模式用SimpleSpanProcessorrelease 切 Batch。这个切换用kDebugMode判断即可。加 resource 信息也很关键。Flutter 应用跑在万千设备上如果 trace 里没有 device model、OS 版本、App 版本排查问题时根本没法还原现场。比如某天线上 AI 回复速度暴跌有了设备信息才能发现是某款低端 Android 机型渲染卡顿拖慢了整体体验。初始化还有一个细节把全局的TracerProvider设置好之后建议在 HttpClient 和 Dio 的拦截器里注册一个“自动创建 span”的钩子。这样网络请求不用每个业务方法手动埋点能覆盖大部分基础场景。具体做法后面细说。3.2 给网络层自动埋点网络层是 Flutter 应用最需要 trace 的地方因为 AI 请求基本都走网络。如果每处手动埋点代码会很难维护我选择在“出口”统一处理。Dio 拦截器是 Darts 生态里最方便的埋点位置class TraceInterceptor extends Interceptor { override Futurevoid onRequest( RequestOptions options, RequestInterceptorHandler handler, ) async { final tracer Tracer.getGlobalTracer(); final span tracer.startSpan( http.request, attributes: { http.request.method: options.method, http.route: options.path, http.url: options.uri.toString(), span.kind: client, }, ); options.extra[_trace_span] span; handler.next(options); } override void onResponse(Response response, ResponseInterceptorHandler handler) { final span response.requestOptions.extra[_trace_span] as Span?; span?.setAttribute(http.response.status_code, response.statusCode); span?.setAttribute(http.response.size, response.contentLength ?? 0); span?.end(); handler.next(response); } override void onError(DioException err, ErrorInterceptorHandler handler) { final span err.requestOptions.extra[_trace_span] as Span?; span?.setAttribute(error.type, err.type.name); span?.setStatus(SpanStatus.internalError); span?.end(); handler.next(err); } }关键点是 Span 必须成对出现开启与关闭要一一对应。我见过不少匆忙接 OTel 的项目span 开了不关或是被异常分支漏关最终 trace 全是“悬垂节点”时间轴完全没法看。用拦截器统一处理的好处是只要请求走了 Diospan 就一定会被关闭处理器天然覆盖成功和异常两条路径。但这里有个隐患如果你在请求头里把当前 traceId 带上后端网关才能把服务端 span 挂到同一条 trace 上。所以拦截器里还会做一步final ctx Tracer.getCurrentContext(); options.headers[X-Trace-Id] ctx.traceId; options.headers[X-Span-Id] ctx.spanId;后端只要读这两个 header就能在 Tracer 里创建 child span。这个逻辑我放在拦截器的onRequest里从当前 Context 取 traceId 和 spanId不用每个业务接口特殊处理。3.3 Flutter 特有的坑Zone 与 Isolate 的上下文传递说一个我踩了很久的坑Dio 的拦截器能拿到当前 Context 吗如果在同一个 isolate 里能。但一旦调用了compute()或Isolate.run()新 isolate 里没有任何全局 Tracer 状态。这导致后台 isolate 里做的耗时操作图片压缩、数据解析、本地模型推理全部无法挂到当前 trace 上。Dart 的机制是isolate 之间内存隔离每个 isolate 有自己的全局状态。你没法“共享”一个全局 TracerProvider。我的方案是把必要的信息作为参数传进 isolate在新 isolate 里重新绑定FutureR traceComputeR({ required Span parentSpan, required FutureR Function() computeFn, }) async { final ctx parentSpan.spanContext; return Isolate.run(() { // 在当前 isolate 里重新初始化一个最小 TracerProvider // 并且把父 context 恢复确保新 span 能挂到原 trace 上 final provider TracerProvider( spanProcessors: [BatchSpanProcessor(SharedExporter.instance)], ); Tracer.setGlobalTracerProvider(provider); Tracer.setCurrentContext( Context(spanContext: ctx), ); return computeFn(); }); }这里SharedExporter.instance是一个全局单例 Exporter所有 isolate 共用同一个 HTTP 连接池。如果没有它每个 isolate 都会创建自己的连接App 的并发高时很容易耗尽连接数。更实际的做法是尽量减少跨 isolate 的计算。Flutter 的 UI isolate 其实比大家想象的能扛很多计算任务其实不是瓶颈。我后来把 AI 场景的数据解析挪回 UI isolate 做用增量解析而不是一次性解析帧率反而更稳定。isolate 只留给真正会卡死的 CPU 密集任务比如大图压缩、复杂 JSON 解析。3.4 手动埋点的正确姿势网络层和 compute 场景搞定之后业务层的手动埋点也要提上日程。比如用户点击一个 AI 功能按钮最简单的埋点是void onAiButtonClicked() { final tracer Tracer.getGlobalTracer(); final span tracer.startSpan(ai.feature.start, attributes: { ai.feature.name: smart_reply, ai.feature.trigger: manual, }); // 执行异步逻辑结束时 span.end() runMyAiProcess().whenComplete(span.end); }但这么写有个问题多人协作的团队每个人埋点风格不一样一会儿ai.feature.trigger一会儿ai_trigger下游分析没法统一。所以 Dartastic 里我预设了一些封装好的业务函数开发者不用直接操作 Tracer而是调用final result await Dartastic.traceAsync( name: smart_reply.run, attributes: {ai.feature.name: smart_reply}, task: () runMyAiProcess(), );这样属性和命名规范都统一了。我的经验是不要在文档里写一千字说“大家请按规范埋点”而是直接提供封装函数让规范“长”在 API 上。人都是懒惰的给什么用什么。手动埋点的另一个原则是“埋入口、埋出口、埋异常”。入口是用户动作出口是结果返回异常是 catch 分支。有了这三个点你就能画出用户的完整行为路径。不需要在函数内部每个步骤都埋粒度太细反而让 trace 爆炸。控制粒度是埋点设计里最容易忽略的事情。3.5 异常捕获与网络上报的完整闭环Flutter 有一个全世界开发者都吐槽的点PlatformDispatcher.instance.onError不比runZonedGuarded好使很多 Crash 你根本不知道发生原因。集成 Dartastic 之后我把异常捕获统一接到了 trace 里FlutterError.onError (details) { final span Tracer.getGlobalTracer() .startSpan(flutter.error); span.setAttribute(error.type, FlutterError); span.setAttribute(error.message, details.exceptionAsString()); span.setStatus(SpanStatus.internalError); span.end(); }; PlatformDispatcher.instance.onError (error, stack) { final span Tracer.getGlobalTracer() .startSpan(platform.error); span.setAttribute(error.type, error.runtimeType.toString()); span.setAttribute(error.stack, stack.toString()); span.setStatus(SpanStatus.internalError); span.end(); return false; };这里不建议把 stack 完整塞进 span 属性尤其生产环境一个 stack 可能几千字符全部上报既浪费流量又可能泄露代码结构。线上环境可以先用error.runtimeType和error.messagestack 写入本地日志需要时再回捞。上报链路也要注意一个实际问题如果 OTel Collector 不在同一个内网Exporter 走公网报告你就要考虑断网、超时、重试。OTLP Exporter 本身内置了重试机制但生产环境我建议上报不要用同步方式而是把 Span 先写入一个本地队列由独立的 worker 批量上报。这样 App 断网时数据不会丢恢复后自动补报。Dartastic 的 BatchSpanProcessor 天然支持这个机制但要确认它把背压处理好队列满了应该丢弃最旧的数据而不是无限制堆积然后 OOM。默认实现是丢弃策略别去改成无限队列。4. AI 场景特化把 LLM 调用做成一条可观测的链路4.1 用 Span 建模一次 LLM 调用AI 场景里的监控不同之处在于一次用户提问不是一个“请求”而是一个“任务”。这个任务可能包含多个 LLM 调用、多次工具调用、多轮上下文拼接。OpenTelemetry 里对 LLM 调用有专门的语义约定草案核心思想是用tracer.startSpan(chat.completions)表示一次完整的 LLM 请求把 prompt、completion、model、usage 等信息放到 Span 属性里。我在实际接入时给 LLM 调用单独封装了一个LlmSpan类final llmSpan Dartastic.startLlmSpan( provider: openai, model: gpt-4o-mini, messages: sanitizedPrompt, // 注意脱敏去掉身份证号/手机号等 temperature: 0.7, maxTokens: 1024, ); try { final response await chatClient.send(messages); llmSpan.setAttribute(llm.response.choices, response.choices.length); llmSpan.setAttribute(llm.usage.prompt_tokens, response.usage.promptTokens); llmSpan.setAttribute(llm.usage.completion_tokens, response.usage.completionTokens); llmSpan.setStatus(SpanStatus.ok); } catch (e) { llmSpan.setAttribute(error.type, e.runtimeType.toString()); llmSpan.setAttribute(error.message, e.message); llmSpan.setStatus(SpanStatus.internalError); } finally { llmSpan.end(); }核心字段就三块模型信息、入参、用量和结果。模型信息用于区分不同模型的表现入参里要重点分析 prompt 的长度因为长 prompt 慢是必然的有了这个字段你才能判断“慢是模型问题还是 prompt 问题”用量里的 token 数是成本核算的基础也是排查限流的关键依据。脱敏这件事我单独强调一下。你在客户端埋点里把整个 prompt 上报上去一旦里面包含了用户输入的个人信息这在很多业务场景下是合规事故。我的做法是在sanitizedPrompt里把所有长度超过 50 的连续数字、邮箱、手机号、身份证号替换成占位符。宁可排查时少一点上下文也不能让隐私数据进日志系统。4.2 流式输出的 Span 设计传统 LLM 调用往往不是一次性返回而是 SSE 流式输出。这给 trace 设计带来了一个现实问题一个 Span 定义的是一次操作但流式响应的时间轴是“开始”到“最终结束”中间有无数次 partial。最简单的做法是整个流式调用用一个 Span结束时间取最后一个 chunk 到达的时间。但这样粒度太粗没法看出“首字延迟”和“字与字之间的平均间隔”而这两个指标恰恰是 AI 体验的核心。我最终拆成两个 Span。一个是llm.stream.connect表示从发起请求到收到第一个 chunk 的耗时即首字延迟另一个是llm.stream.receive表示从第一个 chunk 到最终完成的耗时。这样你可以分别分析首字延迟高通常是网络、排队、预处理慢整体完成慢可能是模型生成慢也可能是端上渲染跟不上。final connectSpan Dartastic.startSpan(llm.stream.connect); final firstChunkReceivedAt DateTime.now(); stream.listen( (chunk) { if (firstChunkReceivedAt null) { connectSpan.setAttribute(llm.stream.ttfb_ms, DateTime.now().difference(firstChunkReceivedAt).inMilliseconds); connectSpan.end(); } // 这里也可以按需对每个 chunk 打点 }, onDone: () { connectSpan.end(); // 兜底防止没有第一个 chunk 就结束 }, onError: (e) { connectSpan.setStatus(SpanStatus.internalError); connectSpan.end(); }, );需要注意的是不要在onData里每收到一个 chunk 就创建一个 span。流式 chunk 可能每秒几十次每个都建 span 会让 trace 体积爆炸。按“一个接收循环一个 span”就好如果你真需要分析单条消息的到达间隔可以在 span 属性里记录 chunk 的数量和总字节数事后反推。4.3 Agent 工具调用链比普通 Request 复杂一个维度如果你在做 Agent 场景事情会更复杂。一次用户提问可能触发模型连续调多个工具先搜索、再读网页、再调一次模型总结。这整个链条如果用简单网络监控你只看到几个孤立的 HTTP 请求根本不知道它们属于同一次“思考过程”。Dartastic 的处理方式是把一次 Agent 运行作为一个 root span每次工具调用、每次模型调用都作为它的子 span。工具调用里还要记录工具名称、入参和出参。这样 Agent 排障效率能提升一个量级final agentRun Dartastic.startSpan(agent.run, attributes: { agent.id: research-assistant-v1, }); final searchSpan agentRun.startChildSpan(agent.tool.search); searchSpan.setAttribute(tool.query, Dartastic OpenTelemetry); searchSpan.setAttribute(tool.results, searchResults.length); searchSpan.end(); final generateSpan agentRun.startChildSpan(agent.llm.generate); // ... 调用 LLM generateSpan.end(); agentRun.end();这里的关键是子 span 的挂载关系。只要你在同一个 Context 里连续 start spanSDK 会自动把它们挂成父子关系。但如果你在回调、Future、async 里打点一定要先Tracer.setCurrentContext(agentRun.spanContext)否则子 span 会飞到别的 trace 上。跨端到端全链路也需要在这里打通客户端的 Agent root span 要把 traceId 传给后端 Agent 网关网关侧再创建后端 Span并且设置 parent 为客户端传过来的 spanId。这样才能串出“用户点击 - 端上 Agent - 网关 - 模型服务 - 工具服务”的全景图。4.4 基于 trace 的 AI 可用性看板当 LLM 调用被正确建模成 Span 后下游的看板和告警就是水到渠成的事了。在 Grafana 里可以用 Tempo 做链路查询用 VictoriaMetrics 或 Prometheus 从 Span 里聚合出指标模型调用量、P50/P95/P99 延迟、错误率、token 消耗趋势。我最常用的几个面板按 model 分组的平均首字延迟这个指标最能反映用户真实体感。按 provider 分组的调用量和错误率谁挂了立刻知道比等用户投诉快得多。token 消耗的环比趋势结合成本看板用防止某个版本 prompt 写得越来越长却没人发现。工具调用失败率Agent 场景里工具失败是最常见的问题源单独监控比在模型错误里捞高效。告警规则我建议只告警“可行动的问题”不要告警“低指标”。比如“P95 首字延迟超过 5 秒”是可以告警的因为说明模型或网络出现性能劣化“错误率超过 1%”要看业务场景AI 场景里模型偶尔超时很常见如果总量不大直接告警只会让团队对告警麻木。我自己的经验是先看一周基线再设定基线 1.5 倍作为告警阈值而且要加一个“持续 5 分钟才触发”的窗口避免瞬时抖动导致半夜连环报警。5. 后端配套存储、查询和可视化5.1 最省心的自托管方案客户端把 OTLP 数据送出去之后后端接收链路我推荐用 OpenTelemetry Collector 作为所有数据的统一入口。它接收 OTLP 格式的 trace、metric、log然后做过滤、脱敏、采样再分发到后端存储。存储层面我用的是 Grafana 全家桶Tempo 存 traceLoki 存日志Prometheus 或 VictoriaMetrics 存指标Grafana 做查询和看板。这套方案的元数据是分三份的通过 traceId 串起来在 Grafana 里可以直接从一条 trace 跳转到关联的日志和指标。这也是选 OTel 生态最大的红利不绑定任何商业产品每一层都可以换。数据量大时VictoriaMetrics 相比 Prometheus 的一个明显优势是支持水平扩展和长期存储而且兼容 PromQL迁移成本极低。如果数据量不大每天百万条 span 以内Prometheus 单机也能扛完全够用。5.2 采样策略别把全量数据都往后端灌讲到后端必须说采样。Flutter 客户端每秒可能生成几十上百个 span全量上报的话你的存储成本会直线上升而且大部分 trace 都没人看纯属浪费。生产环境我开了两个阶段的采样。客户端侧用“tail-based sampling”的简化版本正常情况下只上报错误 trace 和慢 trace比如超过 3 秒的其余按 10% 比例随机采样线上疑难杂症需要排查时临时调到 100% 采样查完再调回。这个逻辑在 Dartastic 的Sampler里配置即可。final sampler ParentBasedSampler( rootSampler: RateLimitingSampler(ratePerSecond: 10), );这里一个很关键的点不要把采样率设死。我见过有人把生产环境采样率设为 1%本质是不想付存储钱结果 AI 链路问题爆发时有问题的 trace 因为概率太低根本采不到排查完全抓瞎。合理做法是“动态采样”按 route 分类核心 AI 接口采样率设为 50%普通页面埋点设为 5%。注意这里说的不是让你每类都写死而是把采样率做成远端配置可以在线调整。5.3 Grafana 看板配置的三个实用思路配置 Grafana 看板我最大的心得是先把“问题场景”想清楚再决定面板长什么样。最容易踩的坑是照着官方模板全量铺面板结果一屏有二十个图没有一个能直接回答“用户为什么反馈 AI 慢”。如果你主要排 AI 场景问题第一屏建议放四个关键面板AI 请求总量按模型分组的堆叠图。用于观察上线/发版后的调用量变化突然暴涨可能是参数配置错误导致客户端死循环调用。首字延迟TTFB的 P50/P95/P99 时间序列。这是 AI 体验最敏感的指标。错误率按模型分组。某个模型突然错误率升高先看是不是这边 prompt 格式改坏了再看是不是模型服务那边限流。Token 消耗趋势。这个不看会出财务事故。这四个面板直接对应“用户说 AI 不好用”时的排查路径先看是不是量变面板1再看是不是慢面板2再看是不是出错面板3最后确认成本有没有失控面板4。不用一盘全塞乱花渐欲迷人眼。第二屏再放全链路依赖关系图比如通过 service graph 看客户端到网关再到模型服务的调用拓扑。第三屏放成本估算。核心思路一屏只回答一个问题看板是给人用的不是展示给老板看的装饰品。6. 接入 Dartastic 过程中最常见的六个坑6.1 启动时初始化导致首帧卡顿很多人一看要初始化 Exporter 和 Provider直接在main()里同步初始化结果首帧延迟明显变长。原因是 OTLP Exporter 在启动时会加载 CA 证书、建立连接池这些都可能触发网络 I/ODart 虽然是异步但这些操作会挤占启动阶段的资源。我的做法是把初始化拆成两步先初始化一个“空壳 Tracer”让埋点代码不会空指针后台再异步初始化真正的 Exporter 和 Provider。在 Dartastic 里提供了createNoopTracer()方法埋点代码无论何时调用都不会崩也不会有额外开销。等真实 Provider 初始化完成后再切换全局实例。6.2 Isolate 里的 Span 变成孤儿前面提过跨 isolate 上下文的问题这里再强调一遍。排查这类问题的方法是在 Grafana 的 Tempo 里查 trace如果发现大量只有 root span 没有子 span 的 trace大概率是子 isolate 里的 span 没有挂到正确 parent 上。解决方案除了前面说的显式传递 context还有一个实用技巧尽量把需要埋点的工作留在主 isolate 里只有纯计算任务才发 isolate。而且 isolate 里如果只是耗时计算可以在完成后把结果带回主 isolate再由主 isolate 统一记录 span。有些开发者为了性能把所有网络解析丢到 isolate结果把链路拆得稀碎性能没提升多少可观测性却完全丢失了。6.3 Exporter 上传失败导致数据丢失BatchSpanProcessor 有重试机制但重试次数有限网络长时间不好时数据会丢。有人把问题归结为“SDK 不行”其实是你没看配置项。我的线上配置是单批最多 50 条队列最大 2048 条超时 30 秒重试最多 5 次每次间隔指数退避。这个配置能把“网络抖动”级别的问题扛过去。真正的大流量场景要注意的是队列满了会触发丢弃策略丢弃的 span 会被计数器记下来。我在 Dartastic 里把这个计数暴露成了指标dartastic.span.dropped_count。如果发现这个指标持续上涨说明上报端吞不下这么多数据你要么提高采集频率要么加大采样率不要干等数据丢了再后悔。6.4 埋点本身成为性能瓶颈埋点代码也有开销。每个 Span 要记录时间戳、属性、创建上下文如果每次事件都要埋点而且属性里有大字符串开销会非常可观。更危险的是如果在 UI 线程的 build 方法里做埋点可能直接影响帧率。我的经验是UI 层不埋点只在事件回调里埋点埋点时属性数量控制在 8 个以内大对象不直接放属性而是先序列化成摘要。还不止我还要关注 BatchSpanProcessor 的上报线程是否在compute里做了大量内存拷贝。OTel SDK 的 BatchSpanProcessor 默认是在一个后台线程处理序列化如果你发现内存抖动明显先检查是不是 Span 属性里放了太多大 JSON。6.5 网络参数与语义约定不一致团队多人协作时最怕的就是每个人对“耗时”的定义不一致。比如前端的llm.ttfb是“从发起请求到拿到第一个字节”后端的llm.ttfb变成“从收到请求到生成完第一个 token”两边的指标对不上根本没法比。这里的解法不是定义文档而是用 OTel 的语义约定semantic conventions做统一。Dartastic 在封装时已经把字段名固定了所有 LLM span 都叫llm.*所有 HTTP span 都叫http.*。你不需要每个人都记住规范只需要用现成的 API 就行。6.6 隐私合规与脱敏最后一个不得不提的坑Flutter 端直接上报用户输入会引发合规问题。尤其 AI 场景里用户可能把身份证号、银行账号、病史都打进对话框里吹牛如果你原样上报到监控后端出了事就是你担责。Dartastic 的脱敏策略是三层第一层请求和响应的 payload 默认不采集第二层需要在 Span 里分析 prompt 时通过sanitizeText()函数过滤手机号1[3-9]\d{9}、邮箱、身份证号第三层上报到非生产环境时直接丢弃 payload只保留长度统计。这套逻辑要固化在整个项目里而不是依赖于开发者的自觉。7. 从监控到优化的闭环建议最后分享一段实际体验。我上线 Dartastic 后的第一个月发现很多用户反馈“AI 回答像念稿”排查时看到llm.usage.completion_tokens的 P95 稳定在 1800 左右而产品定义的摘要功能目标输出是 600。顺着 trace 往下查发现是提示词里没有设置 max_completion_tokens模型默认真实把用户的问题一遍遍扩写。这不是一个性能问题而是一个产品问题。但如果没有 token 监控这个问题可能要等用户大量投诉之后才被发现。所以我的体会是做监控不光是给你排查 bug 用的更是让你理解产品在真实环境里怎么运转的。trace 里的每一个数字都在帮你还原用户手里那个手机上的真实瞬间。在 Flutter 里做这件事确实要多付出不少——环境搭建、Context 传递、Exporter 调优每一环都要自己动手。但大模型应用一旦跑量这里省下的功夫会在排障和优化时加倍还给你。建议你从小流量实验开始先在测试环境跑通采集、展示、告警再逐步扩展到生产。等到某天线上 AI 响应变慢你点开 Grafana 五分钟内就定位到是模型限流、网络波动还是 prompt 冗余时你会觉得这一切投入都值得。