Flowan GEO 雷达平台接收客户下发的批量提问任务,由部署在云服务器上的浏览器 RPA Worker 在元宝、豆包、通义千问、文心一言、DeepSeek、安塔富等平台上真实点击、真实提问, 采集答案、引用来源、截图与 MP4 录屏,最后合并成 Excel 回交客户。 整套系统由一个 Go 控制面、一个 PostgreSQL 真相源和一支多槽位 Worker 机队组成。
本页为架构说明,所有生产 IP、域名、Token、密钥与供应商凭据均已脱敏,仅保留结构与算法。
六层,从客户下发一批问题,一路到把 Excel 送回去。鼠标停在图中任一层,会高亮那一层。
任务、优先级、重试时间、执行租约、账号、出口、审计全部只存在于 PostgreSQL 一处。Redis 挂掉 30 分钟不丢任务。
控制面不推任务。Worker 的每个空闲槽位主动申请下一项工作,分配在一个数据库事务里原子完成。
选任务、去重、算重试、选账号与出口——全都不归 Worker。它只负责开浏览器、点页面、采证据、报事实。
这是这个项目最重要的一条设计决定,也是它被推倒重来过一次的原因。
一句话:「这个任务现在到底是什么状态」这个问题,全系统只允许有一个地方能回答,那就是 PostgreSQL。
早期的链路是这样走的:控制面把任务写进数据库,然后往 Redis 的消息流里塞一条消息,Worker 从消息流里取走, 再存进自己进程里的一份本地任务表,然后开始跑。看起来像一条流水线,其实是三份互相独立、无法一起提交的状态副本。
现在 Redis 里没有任务。它只剩三件事:告诉 Worker「有新活了,别傻等」、收 Worker 的心跳、转发事件和告警。 这三件事丢了都不影响正确性——通知没收到,Worker 下一轮短轮询照样会来问;心跳丢了,租约到期照样能回收。
铁律:数据库里没有对应状态的 Redis 数据,不得触发任何执行。
一共 13 张核心表。值得注意的是没有独立的迁移文件——建表语句就写在控制面启动时执行的一段幂等 SQL 里, 所以改表结构只能加向后兼容的语句,不能改已有语句的语义。
| 表 | 它回答什么问题 |
|---|---|
| tasks | 一条提问的全部身世:属于哪个批次、问哪个平台、开不开深度思考和联网、现在什么状态、跑到第几阶段、进度多少、结果和错误是什么 |
| task_leases | 这条任务现在被哪台机器的哪个槽位拿着、租约什么时候到期、防重放的 fencing token 是多少 |
| task_lease_reports | 上报回执,保证同一次上报重复提交不会被算两次 |
| platform_accounts / account_leases | 各平台的账号池,以及「这个账号此刻被谁占着」 |
| account_usage_policies | 某个平台的账号一天/一小时最多能启动几次,平台是否被临时暂停 |
| proxy_inventory / account_proxy_bindings | 出口 IP 库存(只存 host:port 和能力范围,不存密码),以及账号与出口的绑定关系 |
| external_batches | 客户下发的批次,以及交付到哪一步了 |
| human_incidents / human_incident_tasks | 需要人来处理的事件(验证码、登录失效),以及它卡住了哪些任务 |
| alert_notifications | 已经发过的告警,用来做去重和冷却,避免同一个问题刷屏 |
| worker_snapshots | 每台 Worker 最近一次上报的机器状态 |
一个 Go 二进制,前端页面用 go:embed 打进去了,所以部署就是扔一个文件上去重启。
同一个 HTTP 服务对外开了三类完全不同的门,走的是三套独立的鉴权,互相之间不能借用身份。 这不是为了好看,而是因为这三类调用方的信任级别差了一个数量级。
给自己人用
Session Cookie / Basic Auth / 飞书扫码 OAuth 三选一。飞书登录还能限定只有白名单里的人能进。
/api/v1/*给客户系统用
只认一个 X-Provider-Token 请求头。没配 Token 时直接返回 503,绝不降级成匿名可访问——这是刻意的,宁可停摆不可裸奔。
给自家机器用
一个 Bearer Token,同一个值要在控制面和每台 Worker 的配置里同步存在。换它要四处一起改再重启三个服务。
/api/internal/v1/*这是整个控制面最核心的一段逻辑。Worker 有个槽位空出来了,它来问「给我点活干」。控制面要在 同一个数据库事务里把五件事一次性做完:
设想一台 Worker 卡住了,租约到期,控制面把任务收回来交给了另一台。这时候第一台突然缓过来, 把它那份(早就过期的)结果报了上来。如果没有防护,它会覆盖掉新的执行结果。
办法是:每次发租约都附一个随机 token,之后的续租、进度上报、最终上报都必须带着它。 token 对不上的上报一律拒绝,已经进入终态的任务也不会被迟到的消息重新打开。
每分钟扫一遍,把到期没续上的租约释放掉,账号和出口一并归还,任务放回可执行状态。
它只是在修数据库里已经过期的行,不向任何外部系统重投——这是单真相源下的正常清理,不是跨系统对账。
平台会话是会自己过期的。这个定时器做的事,就是运维看到某个账号掉线后手动点「重新登录」的自动版本。
曾经有账号在「需要登录」状态里躺了两个星期没人发现,平台账号池被悄悄抽干,直到某个批次的吞吐明显塌了才被注意到。
每两分钟看一眼池子够不够,不够就按缺口补货。整个动作在一把数据库咨询锁下进行,所以即使控制面开了两个副本,对外也只表现为一个买家。
57 条路由,按调用方分成三堆。切换看每一堆分别是给谁用的。
这是唯一暴露给公司外部的一组接口。客户把一整批问题打包扔进来,我们跑完把 Excel 送回去; 中间万一回调没送达,客户还可以自己来查状态和结果文件——这两条查询接口就是为「补偿」准备的。
| 接口 | 谁调 | 做什么 |
|---|---|---|
| POST /api/v1/ai/batch/create | 客户 | 下发一个批次,最多 5000 条问题。每条问题带平台、关键词、是否深度思考、是否联网 |
| GET /api/v1/ai/batch/external/{id} | 客户 | 查这个批次跑到哪儿了,逐条问题的状态 |
| GET /api/v1/ai/batch/external/{id}/files | 客户 | 查结果文件在哪儿 |
| GET /api/v1/tasks/{id}/artifacts/{name} | 客户 / 邮件收件人 | 凭签名链接取一份证据(截图、录屏),链接带时效 |
结果 Excel 做好之后,我们先向客户申请一个对象存储的预签名上传地址,PUT 上去,再回调告诉他们这批完成了。
| 接口 | 做什么 |
|---|---|
| POST /external/oss/{providerCode}/upload-url | 申请一个一小时有效的上传地址 |
| POST /external/callback/{providerCode} | 通知批次完成 |
Worker 和控制面之间就靠这几条对话。注意它们全是由 Worker 发起的——控制面从不主动推任务。
| 接口 | 什么时候调 |
|---|---|
| POST /task-leases:acquire | 槽位空了,来要活。带上可安全重试的请求 ID——同一个 ID 重试必须拿回原来那份租约,不能又领走一条新任务 |
| POST /task-leases/{id}/renew | 执行期间定时续租。续租的响应里顺带捎回「有人要取消这条任务」的指令 |
| POST /task-leases/{id}/browser-started | 浏览器真的起来了、平台真的看见我们了。在这之前失败的话,控制面会把消耗掉的重试次数和账号启动次数还回去 |
| POST /task-leases/{id}/snapshot | 发布一份新的账号会话快照(加密后的登录态) |
| POST /task-leases/{id}/report | 报告进度,或报告最终结果。最终上报和资源释放在同一个事务里落地 |
| POST /workers/heartbeat | 心跳:机器负载、槽位状态、各平台登录健康度 |
| POST /proxy-inventory | 上报本机看到的出口库存变化 |
| POST /batch-deliveries/claim · /record-keys · /complete | 认领一个待交付的批次、记下文件位置、报告交付结果 |
运营每天盯着的那个后台。它不只是看板,很多操作是能直接干预生产的。
这是最容易被问到的一块:槽位是干啥的?
槽位(Slot)就是一个完整的、与世隔绝的电脑桌面。 一台 Worker 机器上跑着一个常驻的 Python 进程,这个进程里开了若干个槽位; 每个槽位有自己的虚拟屏幕、自己的浏览器、自己的浏览器用户目录、自己的远程观察端口。 槽位的数量 = 这台机器能同时跑几个任务,全机队的槽位总数就是系统的最大并发。
因为这些平台的页面是给人用的,很多控件(深度思考开关、联网开关、来源面板)只有在真实渲染、真实点击下才有正确的行为; 而且交付物里要有「我们真的操作过」的录屏证据。无头模式既录不出东西,也更容易被识别。
每个槽位都要吃一份显存/内存、一个浏览器进程,还要独占一个出口 IP。 加槽位前得先把出口池的目标数量一起调大——曾经机队从 8 个槽位扩到 12 个,出口池目标还写着 10, 结果两个槽位干等着没 IP 可用,而队列里堆着几百条任务。
这一节讲的是「一条任务落到槽位之后,那个浏览器里到底发生了什么」。
六个平台的页面长得完全不一样,所以系统给每个平台维护一份规格说明,新接一个平台就是从写这份说明开始。里面写着:
聊天页地址、开新会话的按钮在哪、打开后要等几秒页面才算稳。
输入框的坐标、点哪里能让页面获得焦点而不误触其他控件。
答案从哪句话开始、页面上哪些是要裁掉的噪音(「内容由 AI 生成」「相关视频」之类)、来源面板叫什么名字。
页面上出现哪些字样就说明登录失效了,比如「微信扫码登录」「请登录后输入内容」。
用真实的键盘操作,而不是脚本跳转。
顺序是刻意的。一个已经掉线的页面同样会渲染出一个(禁用的)输入框和空空的工具栏, 如果先去检查开关状态,就会把「登录过期」误判成「页面布局变了」这种无害问题。
点击是真的点,但验证靠页面结构:点之前读一次状态,点之后再读一次,确认它真的变了。 有些平台不支持某个组合(比如关掉联网就没法深度思考),这种情况会当场把任务判成「这个要求它做不到」,而不是硬跑出一个假结果。
走剪贴板粘贴而不是逐字输入——问题往往很长,逐字敲又慢又容易被中途的自动补全打断。
先存一份「基线」文本和一份基线的来源链接。之后新增的部分才是这次的回答。
大模型是流式吐字的,页面上没有一个「回答完毕」的信号灯。系统的判断办法是看页面文字还在不在变:
交付要求不只是答案,还要有这次回答引用了哪些网页。这块的麻烦在于各平台的引用卡片做法五花八门:
抓回来的每一条还要清洗:跳过跳转中间页、补全相对地址、按标题相似度去重, 最后只保留标题和绝对地址都齐全的那些——宁可少给,不给残缺数据。
这是交付物里最有分量的一部分——客户买的不只是答案,还有「这答案确实是从那个平台上问出来的」。
裁掉页面噪音后的正文
标题 + 绝对地址,去重后
关键节点的画面
从开始到结束的全程
这些文件都存在私有的对象存储里,不公开。要给客户看的时候,由控制面签发一条带时效的链接—— 链接本身带签名,过期就打不开,不需要把桶开成公开读。
「出口」就是我们的请求最终从哪个 IP 地址发出去。这是整个系统里最脏、最容易出事的一块。
平台限流不认账号,认 IP。三十多个账号如果都从十来个地址出去, 每个地址上的会话频率就高得离谱,一半的请求会撞上人机验证。 所以「多买地址」是唯一能在不降吞吐的前提下压低单 IP 频率的办法。
供应商按来源 IP 放行——只有事先加了白名单的机器才连得上,不需要账号密码。 它的寿命很短,控制面每两分钟就在补货换血。因为鉴权不依赖凭据,所以全机队任何一台白名单机都能用。
靠账号密码鉴权,而密码只装在某一台机器上。所以它只能分配给持有凭据的那台, 别的机器拿到也用不了。数据库里只存地址和范围,不存密码。
供应商的白名单只有五个名额,早就被占满了。后来新加的两台机器直连出口是秒拒——带账号密码也一样, 因为账密只对静态那批有效。它们的解法是:
出口可以花钱买,账号不行——账号要养、会掉线、有日限额,而且掉了没人告诉你。
早期的做法是「某个槽位就固定登录某个账号」,这样一台机器坏了,它上面那些账号就一起停摆。 现在改成账号池:账号只属于账号池,任务分配时临时租一个,用完归还。 这样账号的可用性和机器的可用性解耦了。
每个账号的浏览器登录态(Cookie、本地存储)会被加密后存成快照。 下次不管在哪台机器上用这个账号,都能把快照解开、种进一个新的浏览器目录里,直接是登录状态。
可以给每个平台设「一天最多启动几次、一小时最多几次」,防止账号被平台盯上。 但设得太低会造成一种很难查的故障现象:账号全是健康的,吞吐却是零——因为它们全都在冷却里。 遇到「一切正常但没产出」,先去看这两个数字。
同一个账号尽量固定从同一台机器、同一类地址出去,突然换城市换运营商更容易触发风控。 但这个绑定必须能被解除,否则那台机器坏了账号就跟着陪葬——所以管理台上有一个「解除亲和」的按钮。
客户不看我们的后台,他们只看到一个文件。
整批标记为待交付。
认领是排他的,所以不会两台机器同时交付同一批。认领失败或中途出错,认领会被释放,另一台可以接着来。
严格按客户下发时的顺序排列,答案、来源、录屏链接分别填进对应的列。表头是校验过的——模板对不上直接报错,而不是错位写进去。
在临时目录被清掉之前,先把合并好的文件存进我们自己的存储。否则一旦上传出问题,重跑整批的代价是天文数字。
顺序很关键:先把位置写进数据库,再去上传和回调。这样即使回调丢了,客户来查「结果文件在哪」时我们答得上来。
用客户给的预签名地址,一字不差地带上他们要求的请求头——这类签名对头部很敏感,多一个少一个都会被拒。
回调失败会一直重试到成功为止。
把前面所有部分串起来,看一条问题从进门到出门都经过了谁。
每一条背后都有一次真实的线上事故。新加任何能力之前,先对照这几条。
加新调度能力时,先问它属于哪一格:调度规则在控制面,持久状态在数据库,Worker 只执行,Redis 只加速通信。 把任务所有权抄一份到 Redis 或 Worker 本地,是这个项目专门被重构掉的问题,不要退回去。
任务、账号、出口、租约在一个事务里同生共死。不允许存在「占了账号没有出口」这种中间态。
部署脚本不认「服务是 active 的」,它要求主进程号真的变了、而且进程的启动时间晚于它所运行的那份代码。 曾经出现过「修好了」但进程跑着两天前代码的事故。
浏览器起不来和出口不可用是两回事。曾经把前者报成后者,结果运维照着提示去买代理, 而真正的原因是页面上某个控件超时了。
来源缺地址就不收,水印转码失败就交原片,表头对不上就报错。交付物的可信度比完整度重要。
对外接口在没有配置 Token 时返回 503,而不是变成匿名可访问。安全配置缺失必须是显性故障。
凭据清单文档里写的是「这个 Token 存在哪、谁在用它、换它会连带影响谁」,真实值走服务器权限或密码管理器。
加机器 → 加槽位 → 同步调大出口池目标 → 确认新机器进得了供应商白名单。漏掉任何一步, 新增的槽位就是在那儿空转。