1. 当仪表盘只说“异常”却不说“怎么办”:实时计算最后一个坎
我先描述一个场景,做实时数仓或者实时风控的兄弟应该都不陌生:你盯着监控大屏,突然看到某个核心指标的告警亮起来,比如支付失败率在最近五分钟内从0.1%飙到3%,或者线上订单积压量突然超过阈值。此时你的第一反应是什么?大概率是打开日志平台,手动查链路,翻几个数据源,再根据自己的经验猜原因,然后写规则或者跑临时查询去验证。
这套流程最别扭的地方在于——系统把“有问题”这件事告诉我了,但它完全没有告诉我“接下来怎么办”。告警只是把原始事实丢到你脸上,把“决策”这个最费脑子的步骤留给了值班的人。更麻烦的是,很多异常不是一个简单规则能覆盖的,它可能是上游数据源抖动、某个优惠策略误配、渠道流量异常、甚至外部接口超时等多种因素叠加。值班的人如果没有足够经验,光是定位就得花十几分钟,而在线业务每多抖动一分钟都是肉眼可见的损失。
这个项目想回答的问题非常直接:能不能让实时计算不再止步于“发生了什么”,而是基于流上已经算出来的结果,直接给出“接下来怎么办”的建议?
我选择的方向是给 Flink 的计算链路接入一层 Agent(智能体)能力。这里的 Agent 不是某个具体的产品,也不是什么神秘的框架,而是把大语言模型(LLM)的推理能力封装成流式计算可以调用的服务,让实时任务在识别到异常模式之后,自动触发一个“分析 + 建议”的环节,把原本需要人工完成的排查动作提前做掉一部分。说白了,就是让 Flink 继续干它最擅长的实时聚合和窗口计算,但算完不等于结束,而是把结果交给一个“会思考的助手”去解读、去给建议、甚至去触发后续动作。
这个方案适合谁?如果你做的是实时数仓、实时风控、线上稳定性保障、智能运维,并且已经被“告警疲劳”折磨过——每次告警来了都要靠人肉翻日志定位原因,那这篇文章的思路应该能给你一些启发。这篇文章我会直接从我的实操角度出发,拆解架构设计、关键代码、踩过的坑,以及不同业务场景下应该怎么取舍。
2. 从 Table API 到 Function:给 Flink 装一双“会思考的手脚”
先说结论:把 Agent 的能力接进 Flink 流任务,最直接、最不容易把架构搞复杂的方案,是把 Agent 封装成一个自定义函数(UDF),在 Flink SQL 的查询逻辑里调用它。这种方式最大的好处是你可以继续用 SQL 描述你的实时业务逻辑,团队里的同学不需要人人都会写 Java/Scala 的 DataStream API,就能享受到 Agent 能力的加持。
2.1 核心思路:用 UDF 打通流计算与推理服务
我的第一个 demo 是这样搭的:有一条实时的交易事件流,从 Kafka 读入,经过 Flink 做窗口聚合,得到一个五分钟粒度、按商户维度拆分的支付失败率指标。正常情况下这个指标是一个数值,直接写入 Doris 或者 MySQL 供前端展示。加上 Agent 之后,我在 SQL 里追加了一个字段,专门用来承载 Agent 的“解读”。
从实现层面说,这个 UDF 的职责非常单一:接收从 SQL 里传进来的上下文参数(比如商户ID、失败率、交易量、连续告警次数),拼成一个 Prompt,然后调用部署好的模型推理服务,拿到返回文本,最终把它作为一个字符串字段输出到下游。
INSERT INTO risk_suggestion_sink SELECT merchant_id, window_start, window_end, fail_rate, txn_count, AgentSuggest(merchant_id, fail_rate, txn_count, alert_level) AS suggestion FROM TABLE(TUMBLE(TABLE kafka_transaction, DESCRIPTOR(ts), INTERVAL '5' MINUTES)) GROUP BY merchant_id, window_start, window_end;这段 SQL 里最关键的就是那个AgentSuggest函数。它屏蔽了所有与模型交互的细节,让实时计算链路的“推理”看起来就像一次普通函数调用一样,下游的 sink 也不需要关心这个字段到底是大模型生成的还是从某个规则表里查出来的。
2.2 为什么不用异步 IO:超时、重试与背压的取舍
很多对 Flink 有一定了解的同学第一个问题肯定是:Agent 服务调用是网络 IO,为什么不直接用 Async I/O 来提升吞吐,而是要在 UDF 里同步调用?这个问题我想专门拿出来说说,因为它背后涉及一个容易被忽略的架构取舍。
Async I/O 确实是 Flink 官方推荐的处理外部系统交互的方案,它能让一个并行子任务同时发起多个请求而不至于被网络延迟卡死。但用了 Async I/O 之后,你面临一个非常现实的问题:当你调用的下游是一个 LLM 推理服务,它的响应时间波动范围极大(可能 300ms,也可能 8s),而且模型服务的并发本身就有限,异步请求一旦积压,很容易把下游打爆。我在测试阶段用 Async I/O 压过一次,结果模型服务的排队数瞬间飙高,部分请求直接超时,反而导致整个窗口的写完延后。
所以我最终的方案是:把 Agent 调用放在 UDF 里,走同步调用,但把超时设短(比如 3 秒),并且在 Flink 侧通过并行度来控制对 Agent 服务的最大并发。这样做的本质是把“推高吞吐”让位给“保护下游”,因为 Agent 生成的内容场景是“建议”,不是“每笔交易都要处理的主链路数据”,偶尔丢一两条建议完全不影响核心实时链路,但把 Agent 服务打挂就是事故了。
2.3 调用 Agent 时的输入截断与格式设计
调用模型服务还有一个容易被忽略的工程问题:Prompt 拼接。如果你的参数里包含用户输入的自由文本(比如某个商户的备注信息、渠道上报的异常日志摘要),这些内容可能会很长,也可能包含不可控的格式符号。直接把原始内容丢给模型,既浪费 token,又容易让模型输出不可解析的结果。
我习惯的做法是:在 UDF 内部做“字段级白名单”,只把结构化字段传进去。对于文本类字段,截断到固定长度(比如 200 个字符),并且把字段之间的分隔符统一,避免出现换行、引号等导致 Prompt 结构混乱的字符。同时,我会在 Prompt 里要求模型“只输出 JSON,不要输出其他任何内容”,然后 UDF 里做强校验,解析失败就返回兜底文本。
public static class AgentSuggestFunction extends ScalarFunction { private String endpoint; private int timeoutMs; @Override public void open(FunctionContext context) throws Exception { endpoint = context.getJobParameter("agent.endpoint", "http://llm-service:8080/v1/chat/completions"); String timeoutStr = context.getJobParameter("agent.timeout.ms", "3000"); timeoutMs = Integer.parseInt(timeoutStr); } public String eval(String merchantId, double failRate, long txnCount, String alertLevel) { String prompt = String.format( "你是实时交易风控助手。当前商户 %s 在最近 5 分钟的支付失败率是 %.2f%%,交易量 %d 笔,告警等级 %s。" + "请分析可能原因并给出最多 3 条可执行建议,以 JSON 数组格式输出。", merchantId, failRate, txnCount, alertLevel ); String resp = LlmClient.call(endpoint, prompt, timeoutMs); if (resp == null || resp.isBlank()) { return "{\"error\":\"agent timeout\"}"; } return parseOrFallback(resp); } }从这段代码就能看出来,UDF 的核心逻辑其实很薄,关键还是在超时控制、失败兜底和返回值规范化。做到了这几点,Flink 的稳定性就不会因为接了 Agent 而下降。
3. 让 Agent 带着上下文工作:状态、时钟与事件时间
如果你只是简单地把某个窗口的聚合结果扔给 Agent 去生成建议,那这个项目的价值也就是个“花哨的告警机器人”。真正让它变得有用的,是让 Agent 的每一次“思考”都带上足够的上下文,而 Flink 的状态(State)和窗口机制恰好是承载上下文的最好容器。
3.1 Keyed State 让每次推理都有“前因后果”
在第一个 demo 跑通之后,我很快发现一个问题:Agent 每次看到的都只是当前五分钟的孤立数据。比如某个商户连续三个窗口失败率都不低,但单个窗口的值又没有触发最高级别告警,如果只看当前值,模型会认为“还行,再观察”,但实际上这已经是一个持续恶化的趋势了。
要解决这个问题,就必须把历史信息带到 Prompt 里。Flink 的 Keyed State 正好可以做这件事——按商户 ID(或者任何业务主键)维护最近 N 个窗口的指标快照,在下一次调用 Agent 之前,从状态里取出前几个窗口的数据,拼接成一个“趋势描述”加进 Prompt。
INSERT INTO risk_suggestion_sink SELECT merchant_id, window_start, AgentSuggest( merchant_id, fail_rate, txn_count, TrendState(merchant_id, fail_rate, txn_count) -- 注意这里 ) AS suggestion FROM TABLE(TUMBLE(TABLE kafka_transaction, DESCRIPTOR(ts), INTERVAL '5' MINUTES)) GROUP BY merchant_id, window_start, window_end;TrendState是我封装的一个 UserDefinedTableFunction 和 ScalarFunction 配合使用的复合逻辑,里面用ValueState<List<MetricSnapshot>>存最近 6 个窗口的指标。每次新窗口触发计算时,把旧快照灌进 Prompt,再更新状态。
这个改造带来的提升非常明显:Agent 开始能够说出“已经连续三十分钟失败率超过 0.5%,平均每五分钟递增 0.1%,建议立即核查上游渠道回调链路”这种有“时间感”的话,而不只是“当前失败率较高”的废话。
3.2 事件时间窗口:乱序数据下的“接下来怎么办”
做实时计算的同学对这个场景应该都有共鸣:上游 Kafka 里的数据并不总是按照业务时间严格有序,某一个网络抖动可能导致数据迟到。如果我用处理时间(Processing Time)开窗口,那么迟到的数据会被算到错误的窗口里,Agent 基于错误数据生成的建议自然也是错误的。
所以在设计窗口时,我强制使用事件时间(Event Time),并从 Kafka 消息里解析业务时间戳。这里有一个很关键的配套动作:设置合理的 Watermark 策略和允许延迟(Allowed Lateness)。我线上用的配置是 Watermark 滞后 10 秒,允许迟到 30 秒,迟到的数据会触发窗口的再次计算。
这里有一个容易忽视的细节:Agent 调用本身是会产生“副作用”的(每条建议都消耗 token、消耗模型服务资源),所以窗口的多次触发可能会导致同一个窗口生成多条建议。如果这些建议都往下游写,用户就会被重复告警轰炸。我的做法是在 sink 层加一个基于窗口起止时间和主键的去重,只保留最后一次触发的结果。这个逻辑听起来很基础,但实际排查重复告警的时候会省非常多的时间。
3.3 状态 TTL 与推理白噪声:控制成本的关键
接入了状态之后,随之而来的就是状态无限增长的问题。如果你的业务主键数量很多(比如几百万个商户),每个 key 都维护 6 个窗口的快照,状态后端压力会非常大。我直接把状态的 TTL 设置为 30 分钟,也就是说如果一个商户半小时内没有新的窗口数据进来,它的历史上下文会自动过期。
这里我想多说一句关于“成本”的思考。很多人只关注 Infra 成本,忽略了 LLM 调用的 token 成本和下游告警噪音对值班同学注意力成本的消耗。Agent 给出建议是好事,但如果你每个窗口每个商户都调用一次 Agent,绝大部分调用都是浪费——因为大多数商户的交易是正常的,模型只会告诉你“一切正常,无需处理”。这种“白噪声”建议没有任何价值,反而会淹没真正需要关注的高危告警。
所以我的项目里加了一个前置过滤条件:只有失败率超过 0.5% 或者交易量与基线偏离超过 40% 的商户,才会触发 Agent 调用。这个阈值可以在 Flink 的作业参数里配置,方便业务方动态调整。做了这层过滤之后,Agent 的调用量直接降了两个数量级,成本几乎可以忽略。
4. 输出链路最容易翻车:连接器与类型映射的实战排查
接 Agent 这个环节本身一旦打通,后续真正花时间的反而是那些看起来不起眼的基础设施问题。我在这套系统上线和运维的过程中,遇到了几个特别典型的坑,每一个都值得单独拿出来复盘。
4.1 Doris DateV2 与 Arrow DateDay:一次类型映射报错的完整复盘
第一个坑是往 Doris 写数据时冒出来的,报错信息原文就是:
flink type is datev2, but arrow type is dateday这个报错出现在 Flink Doris Connector 同步数据到 Doris 的环节。我当时的表结构里有一个字段是DATE类型,Doris 内部新版本把它存储成了DateV2,但是 Flink Connector 通过 Arrow 向量化读取时,类型推断出现了不一致——一边认为是DateV2,另一边认为是DateDay。
排查步骤我建议记录一下:
- 先确认 Flink 侧表结构定义的物理类型,看是
DATE、TIMESTAMP还是STRING; - 再去看 Doris 侧实际表的类型,如果建表时用了新版本默认的
DATEV2,而 Connector 版本比较老,就非常容易出现这个问题; - 确认 Connector 版本是否支持 DateV2 映射。我当时的解决方式比较简单粗暴——升级 flink-doris-connector 到支持新类型的版本,同时把同步表结构里的时间字段统一改为
STRING先顶一段时间,避免阻塞线上业务; - 如果不想升级或者升级有限制,可以在 Flink SQL 里用
CAST把类型先转成兼容格式再做 sink。
这个问题给我的最大教训是:实时数仓里最坑的往往不是计算逻辑,而是“类型宇宙”之间的映射差异。每个系统都有自己的一套类型体系(Doris 有 DateV2,Arrow 有 DateDay,MySQL 有 DateTime),它们之间的转换规则不可能完全一致,上线前一定要用真实数据验证一遍。
4.2 JDBC 连接器异常:连接池、网络策略与重试参数
第二个高频问题来自 Flink 的 JDBC 连接器。我这套系统里有一部分维度数据(比如商户的等级信息、渠道配置)是存在 MySQL 里的,实时任务需要定期关联这些维度表,于是用了 Flink JDBC Connector。
运行一段时间后开始疯狂报连接异常,典型的错误就是:
Communications link failure The last packet successfully received from the server was X milliseconds ago一开始我怀疑是数据库连接数被打满,于是调大了连接池上限,结果没有改善。后来排查到根因才明白:Flink 作业所在的容器网络与 MySQL 实例之间隔了一层云网络策略,连接空闲超过一定时间(比如 8 小时)会被中间的网络设备静默断开,而连接池里保存的旧连接并没有感知到,下一次请求继续使用这个“死连接”,自然就报错。
这类问题的常规解法是两个参数配合:
connection.maxRetryTimeout:让连接器在获取连接失败时重试;- 在 JDBC URL 上增加
autoReconnect=true和合适的 Socket 超时配置,让驱动层能更快发现连接已断开。
如果 Flink 版本较新,我更推荐用 Flink CDC 或者把维度表做成维表 Join(Temporal Join),而不是每次都去查 MySQL。维表 Join 会把维度数据缓存到本地状态,既减轻了数据库压力,又规避了网络断连的问题。
4.3 “Flink 一定要 HDFS”是误解:内存状态后端的部署实践
还有一个从热搜词就能看出来的经典误区:“Flink 一定要 HDFS”。这种说法在社区里流传很广,但至少对很多中小规模业务来说是不准确的。Flink 的状态存储分为堆内存、RocksDB 和文件系统,只有你开启了 Checkpoint 并且指定了持久化路径,才需要一个文件系统来存 Checkpoint 文件。如果你只是跑一个 demo 或者在 K8s 里部署一套中小规模的实时计算任务,完全可以用jobmanager.memory.process.size配合本地磁盘路径,把 Checkpoint 写到容器挂载的卷里。
我第一次在测试环境部署 Flink 时,也踩过“没有 HDFS 没法搞 Checkpoint”的坑,后来发现完全可以在conf/flink-conf.yaml里指定:
state.checkpoints.dir: file:///data/flink-checkpoints state.backend: hashmap实测下来,对于并行度不高、状态量在几十 GB 以内的作业,这种部署方式完全够用。只有当状态量达到 TB 级别、需要多 TaskManager 共享状态或者需要极致的容灾能力时,再引入 HDFS 或者其他分布式文件系统也不迟。
话说回来,如果你做的业务量级和复杂度都上来了,还是老老实实上 RocksDB 和 HDFS/S3,这是性能与稳定性的综合考量。但我很不喜欢那种“没有 HDFS 就别玩 Flink”的说法,它把很多本来可以快速验证想法的团队挡在了门外。
5. 实时链路上的两种 Agent 形态:会话式预警与流上决策工作流
项目做到这一步,我开始认真思考 Agent 在实时计算链路上到底有几种“生存形态”。我在自己的项目里尝试了两种完全不同的接法,它们的架构、适用场景、复杂度和容错要求都不一样。
5.1 形态一:会话式预警——Agent 是“分析员”
第一种形态,也就是本文前面一直在描述的,Agent 作为一个“实时数据分析员”,在流计算窗口触发后,把聚合指标加工成建议,然后推送到钉钉/飞书/邮件等渠道,供值班人员参考。
这种形态的核心特征是:Agent 不直接改变业务流程,它输出的是“辅助决策信息”。你可以在告警详情里看到类似这样的内容:
商户 A 在过去 15 分钟内支付失败率从 0.3% 上升到 2.1%,其中信用卡支付占比 87%。可能原因:银行通道限流或回调延迟。建议:1) 联系渠道方确认;2) 临时切流至备用通道;3) 关注后续 10 分钟指标是否恢复。
这种模式的优点是风险极低——即使 Agent 给出的建议完全错误,也不会对线上造成直接影响,最多是被值班同事吐槽“这 AI 又瞎说”。它特别适合先验证业务价值的阶段。我强烈建议任何一个想做 Flink + Agent 的团队,先从这种形态开始。
需要注意的是,这类型建议的时效性敏感度很高。如果 Agent 调用耗时太长,建议到了用户手里已经滞后了,价值就大打折扣。所以我在架构上会尽量让 Agent 服务和 Flink 作业处在同一个 K8s 集群或同一个可用区,用内网地址做调用,实测下来 p99 延迟可以控制在 2 秒以内。
5.2 形态二:流上决策工作流——Agent 是“执行者”
第二种形态,Agent 不仅仅是给建议,还会直接参与业务流程的决策和动作触发。比如通过流计算识别到一个新上线的活动存在薅羊毛风险,Agent 不仅给出“建议限流”的文本,还直接调用下游的风控接口下发限流策略。
这个形态的价值更大,但复杂度和风险也完全不是一个量级。我在实验这个形态时发现几个必须提前解决的问题:
幂等性。Flink 的 At-Least-Once 保证意味着同一条数据可能被重复处理,如果 Agent 每次生成的结果都触发一次外部接口调用,那下游系统必须能处理重复请求。后来我在写法上强制给每次决策生成一个
decision_id(基于 key + 窗口起止时间生成),下游接口按这个 ID 做幂等去重。人工审批兜底。对于高影响的动作(比如限流、冻结、大额赔付),不能让 Agent 单独拍板。我设计了一个“建议 + 审批”的两段式流程:Agent 生成建议后,先写入审批任务表,由值班人员在界面上确认后,才真正调用下游接口。这样既享受了 Agent 快速定位问题的能力,又保留了人类在关键节点上的控制力。
模型出错时的降级方案。我给 Agent 调用设定了一个成功率监控,如果连续 5 次调用失败或者返回格式错误,作业会切换成“规则降级模式”——使用预设的规则模板(比如“失败率超过阈值且连续三个窗口则告警”)继续运行,等 Agent 服务恢复后再切回来。实时系统最不能接受的就是“连兜底都没有”。
5.3 项目演进路线:别一上来就全 Agent 化
综合这两种形态的实践,我给自己定了一个项目演进路线,也给正在考虑类似方向的读者一个参考:
- 第一阶段:规则引擎为主,Agent 的建议作为告警内容的“高亮注释”存在,主要是帮值班同学省去翻日志的时间——也就是形态一的雏形;
- 第二阶段:Agent 开始具备多步分析能力,能自动调用几个只读 API 去拉上下文(比如查商户历史指标、查渠道状态),然后基于拿到的信息给出更精准的判断——这个阶段 Agent 仍然是辅助角色,但它的“工具使用”能力已经开始体现价值;
- 第三阶段:引入人工审批的决策动作链路,也就是形态二的完整落地,Agent 能建议和发起动作,但关键节点由人拍板;
- 第四阶段:在积累了足够多的历史决策日志之后,用这些日志做模型的持续预训练和微调(这也解释了为什么“continual pretraining”和“scaling agents”等词条最近会有热度——Agent 的能力要进步,依赖持续高质量的数据回流),让 Agent 在特定业务领域的判断越来越专业。
很多人一上来就想做第四阶段,结果连第一阶段的告警准确率都还没验证,就直接把大模型接进核心交易链路,最终翻车的概率非常高。我一直坚持一个原则:先用小成本验证 Agent 的“判断力”能不能碾压规则引擎,再考虑要不要让它掌握“执行力”。
写在后面的经验之谈
如果让我总结这套 Flink Agents 项目最值得分享的经验,不是某个具体的技术实现,而是一个认知上的转变:实时计算和 Agent 的结合,不应该理解为“用大模型替换掉实时计算”,而是“实时计算负责把海量数据压缩成高密度的业务上下文,Agent 负责在这个上下文上做推理和决策”。两者是互补关系,而不是替代关系。Flink 的窗口、状态、事件时间语义,恰恰是给 Agent 提供“高质量上下文”的最佳基础设施;而 Agent 的推理能力,则把实时计算结果从“给人看的指标”升级成了“给人用的决策”。
我在实际运行里还有一个体会:整套链路里最耗精力的不是模型调优,反而是那些工程边角——类型映射、连接池配置、网络超时、状态清理。建议你动手做的时候,先把这些基建问题梳理清楚,再追求 Agent 的“智能程度”。否则模型说得再对,数据落不下去、告警推不出来,一切等于零。