最近在给团队做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 一层一层串,遇到跨团队的系统基本串不动。
可观测性做的事情不一样,它把“链路”作为第一公民。一次用户提问被抽象成一条 trace,trace 里面有若干个 span,每个 span 描述一个具体的操作:客户端发起、网关转发、模型推理、工具调用、流式接收、UI 渲染。每个 span 都有开始时间、结束时间、状态和结构化属性。有了这条链路,你可以精确回答“这次对话为什么慢了 3 秒”——大概率是模型推理占了 2.8 秒,客户端渲染只占 200 毫秒。
这里我特别想强调一句:监控的目的是回答“为什么”,不是回答“发生了什么”。日志回答“发生了什么”,指标回答“总量是多少”,只有 trace 能回答“为什么”。AI 应用的问题天然跨越多个组件,所以 trace 不是可选项,是必需品。
1.3 终端侧的可观测性为什么一直被忽略
还有一个现实问题:服务端的可观测性已经相当成熟,但客户端——尤其是 App 端——一直是可观测性的死角。原因也很简单,服务端是你部署的,你能在各种语言里塞 SDK;App 端是别人的手机,你怎么知道用户那台手机上发生了什么?网络抖动、弱网、内存不足、被系统杀进程,这些全部发生在你看不到的地方。
在 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然后初始化:
Future<void> 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 模式用SimpleSpanProcessor,release 切 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 Future<void> 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 全是“悬垂节点”,时间轴完全没法看。用拦截器统一处理的好处是:只要请求走了 Dio,span 就一定会被关闭,处理器天然覆盖成功和异常两条路径。
但这里有个隐患:如果你在请求头里把当前 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 里重新绑定:
Future<R> traceCompute<R>({ required Span parentSpan, required Future<R> 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.message,stack 写入本地日志,需要时再回捞。
上报链路也要注意一个实际问题:如果 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 span,SDK 会自动把它们挂成父子关系。但如果你在回调、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 存 trace,Loki 存日志,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/O,Dart 虽然是异步,但这些操作会挤占启动阶段的资源。
我的做法是把初始化拆成两步:先初始化一个“空壳 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 冗余时,你会觉得这一切投入都值得。