海康员工:机器人业务现在是双休,周一到周四,早九点到晚八点半,周五五点下班,海康真不卷(附Agent面试题)
我是非常看好海康这家公司的,2026 年的 WRC 上,他们就展示了双臂机器人和四足巡检机器人,覆盖工业物流和智能巡检。

只有硬件和软件结合起来,AI Agent 才真的有了用武之地,只有软件还是太局限了。关于机器人,海康目前有三条明确的主线:
- 机器视觉,主要负责工业相机、测量、检测、视觉定位等
- 移动机器人,主要负责工厂和仓库内的货物、托盘、料箱和货架搬运
- 关节机器人,机械臂、控制系统和工业软件,结合视觉执行生产动作。
我也总结了一波海康的护城河业务,主要是 AI Agent 方向,大家可以参考。

这里重点提一下海康在 2026年9月下旬正式发布的全新升级的云端安防软件 Hik-Connect 7,其中引入了名为 HIKO 的新一代 AI 引擎。该引擎的特点是无需更换现有硬件,即可通过云端升级让设备获得智能化能力,包括 AI Guardian、AI Search、AI Assistant。

例如,用户描述一个希望监测的事件,系统就能持续匹配并总结;或者用一句话完成某辆车的访客通行配置。感觉还是很有应用场景的。
简单总结一下,海康最大的优势是物理现场的接入与交付能力,包括:
- 设备和协议接入,Agent 要知道现场发生了什么,也要有工具可调用。海康已有大量感知设备和接入能力。海康的大模型一体化平台覆盖 3 万多款自有硬件型号、600 多个第三方品牌设备。
- 行业场景经验,海康这方面的数字资产显然在整个行业是非常领先的。
- 软硬协同,现场应用需要考虑成像质量、弱网、延迟、算力成本和连续运行。海康同时提供前端设备、边缘计算和软件,具备整体优化的条件。
接下来,继续上我们的硬核内容,每天都接近全力给大家准备一些最常见的 Agent 题目,让大家能在一天最希望学习的时候吃到一些细糠。
我们走起。
content
01、Tool Registry 具体如何实现工具参数校验和异常兜底?
“工具注册表要先告诉大模型有哪些工具、每个工具要传哪些参数,大模型发起调用时,再按工具名找到对应的执行器去执行。”
“PaiCLI 的内置工具在启动时注册,每个工具登记名称、描述、参数的 JSON Schema 和执行器。MCP 服务提供的工具在连接成功后动态注册,名字统一改成 mcp__服务名__工具名,避免两个 MCP 服务的同名工具冲突。MCP 服务返回的参数 Schema 不能直接交给大模型,要先清洗一遍。GLM、DeepSeek 这些模型对 $ref、anyOf 这类写法支持的不太友好,清洗时要把 $ref 去掉,把 anyOf、oneOf 改成普通的 object 类型,候选项写进描述里,超过 1000 个字符的描述直接截断。”

“大模型返回的工具参数是一段 JSON 字符串,执行前要过三道检查。第一道是 JSON 能不能解析,大模型偶尔会少一个括号,或者在 JSON 前后多输出一些。第二道是按注册时的 JSON Schema 校验,必填字段有没有、类型对不对、枚举值在不在范围内,我用的是 networknt 的 json-schema-validator。第三道是业务校验,Schema 只管格式,管不了这个路径到底能不能写入。”
“业务校验里最重要的是路径。相对路径先基于项目根目录解析,再做规范化,把 ../ 这类写法替换掉。然后找到这个路径最近一个已经存在的上级目录,调用 toRealPath 解析软链接,最后判断结果是不是还在项目根目录下面,不在就拒绝。这样既能拦住 ../../etc/passwd,也能拦住通过软链接跳到项目外面的写法,而且对还没创建的新文件同样有效。参数的取值范围要提前规定好,比如 grep 的最大结果数限制在 1 到 200 个之间,read_file 一次最多读 2000 行,写入文件最多 5MB。”

“任何一道检查没通过,就生成一条工具结果返回给大模型。OpenAI 兼容的接口要求,assistant 消息里的每个 tool_call 后面都必须跟着一条对应 ID 的 tool 消息,少一条,下一次请求就会直接报 400 错误。所以校验失败、执行异常、超时、工具不存在,最后都要有工具结果。”
“结果里要写清楚哪个字段错了、错在哪里,比如“参数 path 缺失”、“max_results 必须是整数”。大模型看到具体的错误字段,下一轮基本能自己改对参数。只写一句“工具执行失败”,大模型不知道错在哪,往往会用同样的参数再调用一次。”
“执行阶段的兜底按出错的方式分开处理。工具名不存在,返回“未知工具”;本轮没有开放给大模型的工具被调用了,返回拒绝原因;执行时抛异常,返回“工具执行失败”加上异常信息。shell 命令单条超时 60 秒,到时间先 destroyForcibly 强制结束进程,再返回“命令执行超时,已强制终止”。读文件、搜索代码这类只读工具最多 4 个并行执行,一批共用 90 秒超时,超时的那几个标记成已取消,其他工具的结果照常返回,所有结果按大模型调用的原始顺序排队。”
“工具执行成功也可能出问题,输出太长会把上下文窗口撑满。超过 32000 个字符的输出写到本地文件里,上下文里只保留开头 2000 个字符和结尾 800 个字符,再附上文件路径,大模型需要时可以用 read_file 分段读取。工具结果还会用一对带 trust="untrusted-data" 属性的标签包裹起来,结果内容里如果有伪造的同名标签,就先转义。网页和文件里可能藏着提示词注入,这层标签用来告诉大模型,这里面是数据,不是指令。”

“最后还要防止大模型重复调用。同一个动作或者同一类错误连续出现 3 次,系统给大模型注入一条提醒,告诉它换个思路,每个任务最多提醒一次。不直接拦截,是因为有时候重试确实能成功,比如网络抖动导致的 web_fetch 失败。”
02、上下游串行调用大模型,怎么避免整个业务接口超时?
“我会先分清楚哪些调用是真的需要串行。必须串行的前提是数据有依赖关系,后一步要用前一步的结果。派聪明的问答是一个 ReAct 循环,大模型先决定要不要检索,检索结果回来以后,再由大模型生成回答,这两步有依赖,只能串行。但检索内部的 BM25 关键词召回和向量召回互不依赖,就用 CompletableFuture 并行执行,两路一起等最多 10 秒,耗时从两路之和变成较慢那一路的时间。”

“剩下必须串行的步骤,单独给每一步设置超时是不够的。因为每一步都没有超时,但加起来照样会超超时。所以超时要按整个请求来算,在请求入口处生成一个时间,往下每一步的超时时间取它自己的超时配置和剩余时间中较小的那个。比如检索自己配了 10 秒,但整个请求只剩 6 秒了,检索最多只能等 6 秒。”
“剩余时间不够时,后面的步骤要能主动缩短。重排序要再调用一次模型,剩余时间不够就跳过重排序,直接用 RRF 融合后的排序结果。ReAct 循环剩下的时间不够再调用一轮工具时,就给大模型追加一条消息,告诉它不要再调用工具,直接基于已有的检索结果回答。派聪明在 ReAct 轮次用完时已经在发这条消息了,按剩余时间触发也是同一个做法。”

“模型调用本身的超时要分开设置。派聪明里建立连接最多等 5 秒,两次收到数据之间最多间隔 60 秒,整个调用还有一个总超时,流式回答 180 秒,非流式 90 秒,Embedding 30 秒。只设一个读超时是不够的,模型每隔几十秒输出一个字,读超时永远不会触发,请求却可以拖上好几分钟。”
“流式输出能解决用户这一侧的超时。模型生成完整回答要几十秒,但第一个字通常很快就出来了,边生成边推给前端,用户一直能看到内容在变化,网关也一直有数据在传输,不会因为空闲太久把连接断掉。”
“流式输出解决不了服务端线程被占用的问题。派聪明的问答请求从 WebSocket 进来以后,处理消息的线程只做限流检查、读取对话历史这些准备工作,ReAct 循环交给单独的聊天线程池执行,生成的内容通过 WebSocket 推送给前端。线程池满了就直接返回“系统繁忙,请稍后重试”,不让请求排队。更慢的任务,比如解析一份几百页的文档,接口只返回任务 ID,处理进度和结果通过 WebSocket 推送,前端不用一直等着一个 HTTP 请求返回。”

“高并发时还要防止一个慢的模型服务占满所有线程。派聪明给每家模型供应商单独限制了同时进行的请求数,大模型最多 50 个,Embedding 最多 20 个,超出的请求不排队等待,直接返回失败。一家供应商变慢,最多只占用它自己的这 50 个名额,其他请求用的线程不受影响。Embedding 失败,检索降级成只用 BM25;重排序失败,用 RRF 的结果;大模型还没输出任何内容就失败了,切换到备用供应商。整个请求不会因为中间某一步出错而全部失败。”
03、使用 stdio 子进程通信时,遇到过哪些问题?
“先说 stdio 方式怎么通信。PaiCLI 用 ProcessBuilder 按配置里的命令和参数启动 MCP 服务的子进程,往子进程的标准输入写 JSON-RPC 请求,从标准输出读响应,每条消息占一行。读标准输出的是一个单独的守护线程,每读到一行就解析成 JSON。每个请求带一个自增的 ID,发出请求时把 ID 和一个 CompletableFuture 放进 Map,读线程收到响应后按 ID 找到对应的 CompletableFuture,把结果填进去。”

“通信方式本身不复杂,最先遇到的问题是子进程卡死。子进程往标准错误写日志,如果父进程一直不读,操作系统的管道缓冲区写满以后,子进程的写操作就会阻塞,整个 MCP 服务就会卡住,父进程的请求全部超时。所以 PaiCLI 专门起了一个线程持续读取标准错误输出,然后放到一个 200 行的环形缓冲区,用 /mcp logs 命令可以查看。”
“子进程能稳定运行以后,用户感受最明显的是启动慢。很多 MCP 服务通过 npx、uvx 启动,第一次启动要先下载依赖包,要等 5 到 30 秒。如果几个服务依次启动,CLI 要等很久才能用。所以 PaiCLI 用一个单独的线程池并行启动所有服务,CLI 最多等 8 秒,没启动完的服务转到后台继续启动,用户可以先开始使用 Agent。”
“服务启动以后,工具的参数 Schema 还会遇到不兼容的问题,就是之前讲的 $ref、anyOf 清洗。运行过程中还有一个容易忽略的死锁。MCP 服务的工具列表变化时,会发一条 notifications/tools/list_changed 通知,收到通知后要重新请求一次工具列表。如果在读标准输出的线程里直接发请求、等响应,这个线程就在等它自己去读的那条响应,永远等不到。所以通知要交给另一个单独的线程处理。”

这种方式可能存在哪些风险?
“最大的风险是 MCP 服务的命令以当前用户的身份执行,用户能做什么,它就能做什么。项目目录下的 MCP 配置文件尤其危险,用户克隆了一个陌生仓库,仓库里自带一份 MCP 配置,PaiCLI 一启动就会执行里面写的命令。所以项目级配置里的服务第一次启动前,要先让用户确认,确认过的命令记下来,命令内容变了要重新确认。”
“第二个风险是环境变量泄露。ProcessBuilder 默认会把父进程的所有环境变量都复制给子进程,用户机器上的各种 API Key 都会被传给 MCP 服务。所以子进程的环境变量要用白名单,只保留 PATH、HOME 这类运行必需的变量,再加上配置文件里给这个服务单独配置的变量。”

“第三个风险是标准输出被污染。有的 MCP 服务会把日志打印到标准输出上,读线程读到一行不是 JSON 的内容,解析就会抛异常。如果异常处理写在 ReAct 循环外面,读线程就直接退出了,之后这个服务的所有请求都只能等到超时。所以解析异常要在每一行内部处理,读到非 JSON 的行就记一条日志、跳过这一行,读线程继续读下一行。”
“第四个风险是 MCP 服务会主动发请求过来。按照 JSON-RPC 的规范,服务端也可以向客户端发请求,比如 ping。客户端如果不认识这个请求,也要回一个“方法不存在”的错误响应,不能直接忽略,有的服务会一直等这个响应。”
MCP 子进程的生命周期怎么管理?
“启动阶段,每个服务单独启动,单独做 initialize 握手,握手最多等 60 秒。一个服务启动失败,只把它的状态标记成错误,其他服务照常使用。”
“运行阶段要能发现子进程退出了。进程退出以后标准输出会读到结束,但如果只是读线程结束,服务的状态还显示正常,后面的请求只能一个个等到超时。所以要用 Process 的 onExit 注册回调,子进程一退出,就把服务标记成错误状态,把它的工具从注册表里移除,正在等待响应的请求全部立即失败。然后按指数退避自动重启,比如 1 秒、2 秒、4 秒,连续失败几次就不再重启,提示用户手动处理。对于进程还在、但已经卡住不响应的情况,再定期发 ping 请求做健康检查。”

“关闭阶段要按顺序来。先关闭子进程的标准输入,大部分 MCP 服务读到输入结束就会自己退出;等 1 秒还没退出,调用 destroy 发送结束信号,让它有机会自己清理;再等 2 秒还没退出,调用 destroyForcibly 强制结束。关闭时不发 shutdown 请求,服务已经卡住的话,这个请求本身就要等到超时。JVM 退出时,通过关闭钩子把所有服务都按这个顺序关掉。”
“按这个顺序关闭,npx 启动的服务还是会留下孤儿进程。npx 自己是一个进程,它又启动了一个 node 进程,destroy 只能结束 npx,node 进程还在。所以关闭时要先用 ProcessHandle 的 descendants 方法找到所有子孙进程,从下往上依次结束。如果 JVM 被 kill -9 强制结束,关闭钩子根本不会执行。好在管道会随着 JVM 退出而关闭,规范实现的 MCP 服务读到输入结束会自己退出。对不规范的服务,启动时把子进程的 PID 记到文件里,下次 PaiCLI 启动时先检查这些 PID,还活着的进程先清理掉。”

04、JavaParser 在代码解析和构建代码上下文方面主要承担什么工作?
“JavaParser 在 PaiCLI 里做三件事。”
“第一件是给代码检索切分代码块。普通文本按字数切分,经常把一个方法从中间分开,检索到的代码块不完整。用 JavaParser 解析出语法树以后,每个类生成一个代码块,内容是类声明开头的几行,能看到类名、继承关系和注解;每个方法生成一个代码块,名字是“类名.方法签名”,内容是完整的方法体。这些代码块转成向量存进 SQLite,供 search_code 工具做语义检索。解析失败的文件和非 Java 文件,退回按 2000 个字符分段。”

“第二件是提取代码之间的关系。从语法树里提取 import 了哪些项目内的类、继承了哪个类、实现了哪些接口、类包含哪些方法、方法里调用了哪些方法,存进 SQLite 的关系表。用户用 /graph 命令能查看某个类依赖了谁、被谁继承、方法里调用了哪些方法。”
“第三件是语法检查。Agent 用 write_file 或者 edit_file 写完一个 Java 文件后,立刻用 JavaParser 解析一遍,把语法错误整理成诊断信息,在下一轮调用大模型之前注入上下文,最多 20 条。大模型写错一个括号,下一轮就能看到错误位置并修改,不用等到编译时才发现。”

JavaParser 具体采用什么方式解析代码?
“JavaParser 的解析器代码是 JavaCC 根据 Java 语法文件生成的,在 jar 包里能看到 GeneratedJavaParser 和 GeneratedJavaParserTokenManager 这两个生成的类。词法分析器先把源码切分成关键字、标识符、运算符这些 token,语法分析器再按语法规则自顶向下递归匹配,生成一棵语法树,根节点是 CompilationUnit,代表整个文件,下面是包声明、import、类声明,类声明下面再是字段和方法。”
“PaiCLI 用的是 JavaParser 实例,不是 StaticJavaParser 的静态方法。创建实例时把语言级别设成 Java 17,否则 record、text block、sealed class 这些新语法会解析失败。解析结果是一个 ParseResult,先判断是否成功,成功以后用 findAll 按节点类型取出所有类声明和方法调用,每个节点都带着起止行号,切分代码块时按行号取原文。”

“PaiCLI 没有接入 JavaParser 的符号解析。符号解析要能找到每个被调用方法属于哪个类,需要把项目依赖的所有 jar 包都加载进来,解析速度会慢很多。代码检索和关系图只需要方法名就够用,精确的类型判断交给编译器。”
工程较大时,性能表现怎么样?
“我实测过。拿本地 5 个 Java 项目,一共 5874 个文件、58 万行代码,用和 PaiCLI 一样的配置,每个文件解析一遍,再取出所有类、方法和方法调用。机器是 M3 Max,JDK 21。”

“单线程第一轮 5.5 秒,JIT 预热以后稳定在 4.6 秒左右。并行解析时,4 个线程大约 1.9 秒,8 个线程 2.3 秒,16 个线程反而慢到 3 秒。所以线程数要实测确定,不能直接按 CPU 核数配置。并行时要注意,JavaParser 官方文档明确写了实例不是线程安全的,多个线程共用一个实例,会出现解析结果不完整的问题,要用 ThreadLocal 让每个线程持有自己的实例。”
“这个数据说明,解析不是索引的瓶颈,瓶颈在 Embedding。这 5 个项目一共切出约 3.7 万个代码块,我用本地 Ollama 的 nomic-embed-text 模型实测,一个代码块一个代码块地请求,平均每个 16 毫秒,算下来要 10 分钟左右,是解析耗时的一百多倍。改成每次请求 10 个代码块,平均每个 8 毫秒,时间能减少一半。”
“所以大工程要优化的是 Embedding 的调用次数,办法是增量索引。每个文件记录内容的哈希值,重新索引时只处理哈希变了的文件,删除这个文件原来的代码块和关系,再写入新的。日常开发一次只改几个文件,重新索引时只需要为这几个文件的代码块调用 Embedding。再加上文件大小上限,超过 1MB 的文件跳过,这类文件一般是生成的代码,没有检索价值,解析起来还占内存。”

05、大量 Agent 并发执行时,Java 后端频繁 GC 的原因和优化思路是什么?
“临时对象多,本身不一定是问题。我测 JavaParser 时看过一组数据,解析 2000 万个字符的源码,一共分配了大约 4.8GB 的对象,堆只有 2GB,Young GC 发生了 70 次,加起来只停顿了 0.1 秒左右,Old GC 一次都没有。临时对象创建出来、用完、下一次 Young GC 就回收了,回收成本很低。”

“Agent 场景麻烦在请求时间长。一次大模型调用要几秒到几十秒,一个 ReAct 任务还要调用好几轮。请求处理期间,对话历史、工具结果、检索到的文档片段都被这个请求引用着,没法回收。这期间发生好几次 Young GC,这些对象一直活着,在 Survivor 区来回复制,年龄到了阈值就被晋升到老年代。并发的 Agent 越多,被晋升的对象越多,老年代很快被占满,就会出现 Mixed GC 甚至 Full GC,停顿时间明显变长。”
“另一个原因是大对象。PaiCLI 每一轮调用大模型,都要把完整的对话历史转成一个 JSON 字符串作为请求体,上下文越长,这个字符串越大,中文按每个字符 2 个字节存储,几十万个字符就是几百 KB 到几 MB。G1 里超过半个 Region 大小的对象叫巨型对象(Humongous Object),会直接分配到老年代。堆比较小的时候 Region 只有 1MB 或 2MB,这种请求体基本都是巨型对象,每调用一轮大模型,老年代里就多出几个大对象。”

“所以优化之前要先确认问题出在哪里。打开 GC 日志(-Xlog:gc*),看是 Young GC 太频繁,还是 Mixed GC、Full GC 多,有没有巨型对象分配;再用 JFR 采样对象分配,找到分配最多的代码位置。不先看数据就调 JVM 参数,很可能调错方向。”
“确认是晋升和巨型对象的问题以后,就从源头减少这类对象的分配,重点是那些占内存多、存活时间长的对象。请求体不先拼成完整的字符串,用 Jackson 的 JsonGenerator 直接写进 HTTP 请求的输出流,内存里就不会出现一个完整的大字符串。工具输出超过 32000 个字符就写到文件,上下文里只留开头和结尾的预览。旧的工具结果定期清理,只保留最近 3 条完整内容。ObjectMapper 和 HTTP 客户端都是全局单例,不在每次请求时新建。”

“减少分配降低的是单个请求占用的内存,并发的 Agent 越多,同时存活的对象还是很多,所以还要控制同时在内存里的数据量。派聪明解析文档时,用信号量限制同一时刻最多解析 2 个文件;后台线程每秒检查一次堆使用率,超过 80% 就暂停 Kafka 消费,降到 70% 以下再恢复。大模型调用也一样,给每家供应商设置最大并发数,超出的请求直接失败,不在内存里排队。”
“这些做完以后,再调整 JVM 参数。派聪明的启动参数把 -Xms 和 -Xmx 设成一样,避免堆来回扩容、缩容;开启 HeapDumpOnOutOfMemoryError,出了 OOM 有堆转储文件可以分析;开启 ExitOnOutOfMemoryError,OOM 以后进程直接退出,由外部重启,不带着不确定的状态继续运行;还设置了 G1PeriodicGCInterval,系统空闲时也定期做一次 GC,堆使用率能及时降下来,暂停的 Kafka 消费也能早点恢复。如果堆比较大,又对停顿时间要求高,JDK 21 可以换成分代 ZGC,停顿时间基本不随堆的大小增长。”
06、为什么选择 Kafka 进行文档解析任务分发?相较于 Spring 异步线程池有哪些优势?
“Spring 的异步线程池把任务放在 JVM 内存的队列里,进程一重启,队列里还没执行的任务就全部丢了。文档解析一个任务可能要几分钟,应用发版、扩缩容都会重启进程,任务丢失是很常见的情况。Kafka 把任务持久化到磁盘,生产者设置了 acks=all,消息写进所有副本才算发送成功,应用重启以后,消费者从上次提交的位移接着处理。”
“第二个优势是多个实例可以分担任务。线程池只能执行本机提交的任务,用户的上传请求落到哪个实例,解析就在哪个实例上执行,实例之间的负载可能很不均衡。Kafka 的消费者组会把分区分配给各个实例,加实例就能分担解析任务,不用改代码。”

“第三个优势是失败处理。派聪明的消费者处理失败后,等 3 秒重试,最多重试 4 次,还是失败就把消息转到死信队列。死信队列有专门的消费者,把失败的消息写进数据库并打告警日志,后续可以排查、手动重新投递。线程池里的任务抛了异常,要自己写重试和记录失败的代码。”
“用 Kafka 要额外解决一个问题,数据库事务和发送消息要么都成功,要么都不成功。合并文件时要更新文件状态,同时发一条解析消息,如果先提交事务再发消息,提交成功以后进程崩溃,消息就丢了。派聪明用的是 outbox 模式,在同一个数据库事务里更新文件状态、写一条待发送记录,事务提交以后再异步发送到 Kafka。发送失败也没关系,定时任务每 5 秒扫描一次未发送的记录,按指数退避重新发送。”

Kafka 重复消费怎么避免或处理?
“先说重复消费是怎么产生的。消费者处理完消息,还没来得及提交位移就崩溃了,重启后这条消息会被再投递一次。还有一种更隐蔽的情况,一份大的扫描版 PDF 做 OCR 可能要几分钟,如果两次 poll 之间的间隔超过了 max.poll.interval.ms,默认是 5 分钟,Kafka 会认为这个消费者已经失效,把它的分区分配给别的消费者,同一条消息就被处理了两次。所以我把 max.poll.records 设成 1,每次只拉一条消息,max.poll.interval.ms 设得比最长的处理时间更长。”
“配置只能减少重复消费,不能完全避免,最后还需要处理逻辑本身是幂等的。消费者拿到消息,先看文件的状态,已经是完成状态,说明之前处理过了,直接跳过,Embedding 接口也不会被重复调用。”

“状态不是完成,也可能是另一个消费者正在处理。所以要先认领任务,用一条带条件的更新语句,把状态改成处理中,同时记下开始处理的时间,条件是当前状态不是处理中,或者上一次心跳已经超时。两个消费者同时执行,只有一个能更新成功。后面每写完一批文本分块更新一次心跳,更新心跳、标记完成、标记失败这些语句都带上认领时记下的开始时间作为条件,任务被别人重新认领以后,原来的消费者更新失败,就放弃这次处理。”
“认领成功以后,先按文件 MD5 把这个文件之前写进去的数据全部删除,ES 里的向量和 MySQL 里的文本分块都要删除,再从头解析。写入时还有两层保证,ES 文档的 ID 是文件 MD5 加分块序号,重复写入会覆盖原来那条;MySQL 的文本分块表有文件 MD5 和分块序号的联合唯一索引,重复插入直接报错。”

短时间上传大量文档时,Kafka 是怎么削峰的?
“用户上传请求的速度和解析的速度是分开的。合并接口只负责合并文件、写一条待发送记录,写完马上返回,用户上传多少文件,就往 Kafka 里写多少条消息。消息存在磁盘上,积压几万条也不占应用的内存。”
“消费者按自己的速度拉取。Kafka 是消费者主动拉取消息,不是 Kafka 推给消费者,消费者处理完手上这批消息,才会去拉下一批,拉取的速度由处理速度决定。派聪明同一时刻最多解析 2 个文件,第 3 个文件等 120 秒还拿不到名额,就抛异常,让 Kafka 过一会儿重新投递。堆使用率超过 80% 时,暂停 Kafka 消费,已经在处理的任务继续做完,降到 70% 以下再恢复拉取。”

“削峰只是把高峰时的任务推迟处理,积压太多,用户等的时间也会变长。所以还要监控消费者的积压量,积压超过阈值,就增加分区和消费者实例。分区数决定了消费者组最多能有多少个消费者同时工作,所以分区数要按高峰期需要的消费者数量提前规划。”
流式处理时的阈值是依据什么设置的?
“派聪明用 Tika 流式解析文档,Tika 每解析出一段文字,就交给自定义的处理器,处理器把文字追加到一个 StringBuilder 里,长度达到 1048576 个字符,也就是 1M 个字符,就作为一个父块,切分成 512 个字符的文本分块写进数据库,然后清空 StringBuilder,继续接收后面的文字。”

“这个阈值有上限也有下限。我的推断是这样算的。上限看内存,一个父块在内存里会同时存在三份,StringBuilder 本身、toString 复制出来的字符串、切分出来的文本分块,文本分块之间还有 100 个字符的重叠,比原文更长一些。中文按每个字符 2 字节算,1M 个字符的父块约 2MB,三份加起来约 6MB,同时解析 2 个文件也只有十几 MB,在 1GB 的堆里占比很小,留出了足够的余量。”
“下限看切分质量。每个父块的末尾都是直接切断的,可能把一句话从中间切开,切断处前后两个文本分块也没有重叠。父块越小,切断的次数越多,被破坏的句子越多。父块设成 1M 个字符,一般的文档整篇就是一个父块,不会出现这种情况;几百万字的大文件也只切断几次,对检索效果的影响可以忽略。”
“后面向量化用的批量大小 10 是另一个依据,阿里百炼的 text-embedding-v4 接口一次请求最多只能传 10 条文本,派聪明每次从数据库读 10 个文本分块,正好凑成一次请求。”
07、黑盒生产者速率不可控,有界队列丢数据、无界队列会 OOM,怎么重构这个生产消费模型?
“生产速度控制不了,消费速度在一段时间内又跟不上的话,多出来的数据总得存在某个地方。内存放不下,就只能放到比内存便宜、容量大得多的地方,也就是磁盘。所以我会把整个模型改成内存加磁盘两级缓冲。”

“内存这一级用有界队列,单机吞吐要求高的话用 Disruptor 的环形缓冲区。生产者写入时用不阻塞的 offer,不用会阻塞的 put。生产者是黑盒,它的线程被阻塞以后会出什么问题我们不知道,可能超时,也可能自己把数据丢掉。”
“offer 失败,说明内存队列到了上限,这时数据不丢弃,改为追加写入本地磁盘上的日志文件。顺序追加写入磁盘的速度很快,单机可以用 Chronicle Queue 这类基于内存映射文件的持久化队列。需要注意的是,只要开始往磁盘写,后面来的数据也要先写磁盘,等磁盘里积压的数据被消费完,再切换回内存队列,不然新数据走内存、旧数据还在磁盘里,处理顺序就乱了。”
“如果数据一条都不能丢,内存队列也不能用。进程崩溃时,内存队列里还没处理的数据就丢了。这种情况下生产者的数据先写磁盘再消费,相当于每条数据都先写一次日志,延迟会高一点,但进程重启以后可以从日志里恢复。”

“单机磁盘也有上限,生产者的流量大到一台机器扛不住时,就在生产者后面加一层接入服务,接入服务只做一件事,把数据原样写进 Kafka。Kafka 按配置保留几天的数据,相当于一个容量很大的持久化缓冲区;消费者组按分区横向扩容,积压增长就加消费者实例。接入服务本身没有状态,写入 Kafka 时按批次发送,吞吐量很高,生产者的峰值基本都能接住。”
“消费端也要提高处理效率。一条一条处理改成批量处理,比如每次取 100 条,一次批量写数据库。按积压量自动扩缩容消费者,积压增长就扩容,积压降下来再缩容。”

“最后要考虑磁盘也快写满的情况,这说明生产速度长期超过了消费能力,加缓冲已经解决不了,只能在业务上做取舍。监控数据会采样保留,同一个 key 的状态更新只保留最新的一条,低优先级的数据先写进便宜的冷存储,等高峰过去再补处理。每一种取舍都要记录丢弃或者延后处理了多少数据,并触发告警,不能直接丢掉。”
ending
AI 时代,我觉得抵触学习的情绪千万不要有。
因为对于我们普通人来说,你想要人上人,这就是最好的机会。
因为 Opus 5.5 这些顶级模型,包括 DeepSeek V4.1 Flash,他们就是一个顶级的老师,换在以前你花多少钱都请不到。
现在,我们可以随时随地请教他们。
而我们,只要保持学习的劲头,坚持一个月两个月,你就会发现身上的变化,当你足够强了。
就能找到一个相对更好的工作,一个相对不那么卷的工作。
我们下期见。
