0%

这篇解决什么问题

上篇:Flow Graph 入门:用 TypeScript 实现工业视觉流程执行器

上篇已经得到一条可运行的最小链路:

1
schema -> validate -> compile -> execute -> trace

但工业视觉和机器人应用软件还会遇到更难的问题:画布如何保存完整语义、一帧一帧的数据如何流动、下游变慢时如何限制上游,以及一个名字叫 execute 的节点为什么不能自动获得真实硬件能力。

这篇不再复制一套完整代码,而是建立下一阶段的工程边界。重点是:

  • React Flow 只是编辑器,不是运行时。
  • 流式不是“拓扑排序执行得快一点”。
  • 能自由拖线,不代表能自由获得设备副作用。
  • 工业安全必须由运行端能力和 fail-closed 规则保证。

1. 先回顾上篇的边界

上篇的执行器是确定性的批式 DAG:一张图运行一次,每个节点运行一次,下游等上游产出后继续。

它已经能证明:

  • 边精确连接端口。
  • 端口类型在加载期检查。
  • 必填输入不能漏接。
  • 顶层图不能有环。
  • 图先编译,再按稳定顺序运行。

它还不能表达:

  • 一个 source 连续产生多帧。
  • branch 未选中的路径如何标记。
  • 多个上游何时算“到齐”。
  • 队列满时上游该等待、丢弃还是降采样。
  • 什么条件满足后才允许真实设备动作。

接下来不是给现有 for 循环不断加 if,而是逐项定义这些语义。

2. React Flow 只负责编辑,不定义运行语义

React Flow 的 handle 很适合表示端口。关键是保存时不能把端口信息压扁成 A -> B

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import type { Edge, Node } from "@xyflow/react";

interface FlowEdge {
id: string;
from: { node: string; port: string };
to: { node: string; port: string };
}

function toFlowEdge(edge: Edge): FlowEdge {
if (!edge.sourceHandle || !edge.targetHandle) {
throw new Error(`edge ${edge.id} must connect explicit ports`);
}
return {
id: edge.id,
from: { node: edge.source, port: edge.sourceHandle },
to: { node: edge.target, port: edge.targetHandle },
};
}

对应关系必须直接、可逆:

1
2
sourceHandle -> 输出端口 ID
targetHandle -> 输入端口 ID

节点坐标不影响执行,应单独保存:

1
2
3
4
5
6
7
8
9
interface EditorMetadata {
positions: Record<string, { x: number; y: number }>;
viewport?: { x: number; y: number; zoom: number };
}

interface EditorDocument {
graph: FlowDocument;
metadata: EditorMetadata;
}

拖线时的类型校验只是体验优化。后端或本地 runtime 仍要重新校验,因为 JSON 可能来自文件、CLI、测试 fixture 或旧客户端。

3. 编辑模型、编译模型和运行模型

把三种模型塞进一个巨大对象,往往会让 UI 状态、持久化格式和临时运行状态互相污染。

1
2
3
编辑模型:节点配置、端口边、坐标、分组和注释
编译模型:拓扑顺序、直接边绑定和资源引用
运行模型:消息 handle、节点状态、取消信号和 trace
1
2
3
4
5
6
7
flowchart LR
E[Editor Document] --> V[Validate]
V --> C[Compile]
C --> P[Static Plan]
P --> R[Runtime State]
R --> T[Trace]
T --> E

Flow 编辑不等于运行时必须创建重型消息系统

端口图是语义层。单进程执行时,可以在加载期把边解析为直接绑定:

1
detect.detection -> adjust.detection

运行时只需要拿到已绑定的上游输出并调用 runner,不必每个节点都查全局表,也不必为低频 DAG 引入分布式 broker。

图像和点云也不应沿边复制本体。端口消息传 FrameHandle 或引用,实际数据由资源存储管理:

1
2
3
4
interface FrameHandle {
frameId: string;
kind: "image_2d" | "point_cloud";
}

选择这种分离的代价是:schema 变更时要同时考虑编辑文档迁移和编译器兼容,不能靠一个对象“到处都能用”来逃避版本治理。

4. Fan-out、fan-in、barrier 和 branch

这几个术语经常同时出现,最好逐个定义。

Fan-out:一个输出发给多个下游

1
2
3
flowchart LR
C[Capture] --> D1[Detect Defect]
C --> D2[Estimate Pose]

若 payload 很大,两个下游应共享只读 handle,而不是各复制一份图像。

Fan-in:多个上游汇入同一节点

1
2
3
flowchart LR
D[Detection] --> A[Decision]
P[Pose Estimate] --> A

fan-in 只描述结构,不自动说明节点何时执行。节点需要一个明确的等待规则。

Barrier:等一组条件全部满足

例如 decision 必须同时收到 DetectionResultPoseEstimate。在批式 DAG 中,required inputs 到齐即可运行;在流式系统中,还必须知道哪些消息属于同一个工件或同一帧。

关联键至少包含:

1
(runId, itemId)

不能只按端口收集,否则可能把工件 A 的检测结果和工件 B 的位姿拼成一组。

Branch:只激活一条路径

branch 的两个输出可以是 passreview。未选中的路径应该标记为 skipped,不是 failed

这三种状态不能混为一谈:

状态 含义
missing 本应有数据,但尚未到达或确实丢失
skipped 分支语义明确决定不执行
failed 节点尝试执行后出错

如果不显式区分,fan-in 节点很难判断应该继续等待、跳过还是报错。

5. 从批式 DAG 到流式 Flow

批式 DAG 的执行单位是“节点的一次运行”;流式 Flow 的执行单位是“消息的一次到达”。

维度 批式 DAG 流式 Flow
调度单位 节点 消息或 item
source 产出 一次结果 多次 emit
完成条件 节点 Promise 完成 需要显式 end/complete
内存风险 一次运行的数据 队列可能持续增长
背压 通常不明显 必须定义
barrier required 输入到齐 同一关联键的数据到齐

一种最小流式 runner 接口是:

1
2
3
4
5
6
7
8
9
10
11
12
interface OutputEvent {
port: string;
itemId: string;
value: unknown;
}

interface StreamingNodeRunner {
run(
inputs: AsyncIterable<OutputEvent>,
context: RunContext,
): AsyncIterable<OutputEvent>;
}

Backpressure 不是性能优化,而是内存上界

假设相机每秒输出 30 帧,检测节点每秒只能处理 10 帧。若输入队列无限增长,系统只是把“处理不过来”延迟成“稍后内存耗尽”。

使用有界 channel 时,队列满后必须选择策略:

  • await:让 producer 等待,适合不能丢数据的离线处理。
  • drop-oldest:只保留新数据,适合实时预览。
  • sample:按频率采样,适合监控界面。
  • reject:明确失败,适合必须保证完整批次的流程。

策略属于业务契约,不能由底层队列随意决定。

实用升级顺序

  1. 先让一个 source 能连续 emit。
  2. 让一条线性链处理多消息。
  3. 增加 end 信号和取消。
  4. 增加有界队列和背压策略。
  5. 再加 fan-out。
  6. 最后实现按 (runId, itemId) 收集的 barrier。

不要一开始同时引入分支、循环、并行、流式和分布式执行。

6. 取消、超时、重试和 trace

取消必须传到真实 I/O

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
async function withTimeout<T>(
operation: (signal: AbortSignal) => Promise<T>,
timeoutMs: number,
parentSignal: AbortSignal,
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(new Error(`timeout after ${timeoutMs}ms`)),
timeoutMs,
);
const cancelFromParent = () => controller.abort(parentSignal.reason);
parentSignal.addEventListener("abort", cancelFromParent, { once: true });

try {
return await operation(controller.signal);
} finally {
clearTimeout(timer);
parentSignal.removeEventListener("abort", cancelFromParent);
}
}

只在外层 Promise.race() 一个 timeout,会让调用方停止等待,却不一定停止底层 I/O。对设备动作而言,这是假取消:UI 显示已停止,设备请求仍可能继续。

重试要同时满足三个条件

  1. 错误明确标记为 transient(暂时性故障)。
  2. 操作幂等,或带稳定 idempotency key。
  3. 重复执行不会制造额外物理副作用。

网络读取可能重试;机器人移动、PLC 写入和工艺派发不能默认重试。

Trace 是统一的可观察性基础

至少记录:

1
2
graphVersion, runId, nodeId, status, durationMs,
retryCount, runMode, input/output summary, errorCode

图像和点云只记录 handle、尺寸和摘要,不把整个 payload 写进日志。Trace 同时服务于节点高亮、性能分析、失败定位、运行回放和安全审计。

7. L1 原子节点与 L2 复合节点

L1(Level 1)原子节点只做一件事,例如:

  • capture:产出 frame handle。
  • detect:frame -> detection。
  • collect:按关联键收集消息。
  • log:输出摘要。

L2(Level 2)复合节点把常用链封装成一个外部契约,例如:

1
InspectPart = capture -> detect -> aggregate

复合节点需要定义:

  • 对外 typed ports。
  • 外部端口到内部悬空端口的映射。
  • config 如何传入内部节点。
  • trace 默认折叠还是展开。
  • 子图版本和依赖。

选择 L1/L2 分层的收益是:开发者可展开调试,操作员可使用更少、更贴近工艺概念的节点。代价是复合节点需要单独的版本、迁移和 trace 展示规则。

8. 六个值得保留的架构决议

下面把设计演进整理成六张决议卡。它们是通用工程结论,不依赖某个具体项目。

8.1 从隐式黑板到 typed edges

背景问题:节点通过字符串后缀、全局变量名或扫描共享对象寻找上游数据。图能运行,但数据依赖不在图上,错误通常到运行期才出现。

考虑方案:继续靠文档约定;统一显式变量名;让业务产物通过 typed edges 传递。

最终选择:业务数据沿 typed edges 传递;context 只保留 run ID、日志、取消信号等运行能力。

理由:依赖可见,端口可在加载期校验,多个相同类型的 source 也不会因命名猜测而冲突。

代价:需要维护端口 schema 和图迁移,旧的隐式数据引用不能自动继续工作。

8.2 编辑是 Flow,执行是编译后的静态计划

背景问题:直接让运行时解释画布对象,会不断扫描边、读取 UI 字段,并把展示状态带入执行逻辑。

考虑方案:运行时直接解释编辑 JSON;每次执行动态查找;加载时编译静态计划。

最终选择:保存完整端口图,加载时校验、拓扑排序并绑定边,运行时消费静态计划。

理由:编辑语义完整,同时减少运行时动态查找;确定性也更容易测试。

代价:编译模型需要缓存失效和版本管理,编辑后必须重新编译。

8.3 L1 原子节点与 L2 复合节点分层

背景问题:全用原子节点会让操作画布被大量胶水步骤淹没;全用大黑盒又难以组合和调试。

考虑方案:只保留原子节点;只提供固定流程;原子节点与用户可复用复合节点并存。

最终选择:L1 提供清晰、可测试的原子能力,L2 封装高频工艺和受控流程。

理由:兼顾组合能力和操作员认知负担,调试时还能展开内部链。

代价:复合节点的端口映射、版本和 trace 展开需要额外设计。

8.4 教学节点不等于真实硬件能力

背景问题:把 registeradjustexecute 做成可自由组合且都能真实运行的节点,可能允许用户绕过必要的安全判断。

考虑方案:相信画布连线正确;在每个节点重复安全逻辑;把真实能力收敛到受控复合节点和 runner。

最终选择:细粒度节点可用于 simulate、教学和 dry-run;真实纠偏通过受控复合节点调用单一 runner。

理由:安全边界不依赖 UI,不会因为多一种连法就出现第二条设备写入路径。

代价:模拟图和真实能力不完全一一对应,UI 必须明确标识哪些节点仅用于模拟。

8.5 工位 barrier 与数值 gate 正交

背景问题:数据齐全不代表设备已到位;设备到位也不代表调整量安全。

考虑方案:只设一个综合布尔值;只检查工位;分别建模两道门。

最终选择:工位 barrier 检查数据和设备状态,数值 gate 判断调整量,两者都通过才允许真实动作。

理由:两道门处理不同故障,失败原因可观察,也便于分别测试。

代价:运行状态更多,需要明确超时、错误码和操作员处理流程。

8.6 批量流程把计算与派发分开

背景问题:计算节点内部直接下发设备,会让中间产物无法预览、重放和统一审核,多项计算也难以在最终派发前叠加。

考虑方案:边算边发;每个调整节点自行下发;先计算全部调整,再通过 barrier 统一派发。

最终选择:纯计算节点产出新数据,受控派发节点执行副作用。

理由:中间结果可 trace、可 dry-run、可比较;批量数据齐备后再进入统一安全路径。

代价:需要保存中间产物,并处理它们的版本、生命周期和一致性。

9. Simulate、real 与能力注入

run mode 不是 UI 上的装饰开关,而是 runtime 的强制策略。

Mode Runner Barrier Numeric gate Result
simulate absent any any run fixture only; no hardware
simulate present any any still simulate; no hardware
real absent any any fail-closed
real present not ready any fail-closed
real present ready reject fail-closed
real present ready accept controlled dispatch

节点名称不是权限

图中出现 execute,不代表它天然能控制设备。真实副作用还必须同时满足:

1
2
3
4
runMode == real
AND runner 已注入
AND barrier ready
AND numeric gate accept

runner 是 capability(能力)对象,只由应用装配层注入:

1
2
3
4
5
6
7
interface MotionRunner {
dispatch(command: ApprovedCommand, signal: AbortSignal): Promise<void>;
}

interface RuntimeServices {
motionRunner?: MotionRunner;
}

simulate 模式,即使 runner 存在也不能调用。real 模式缺 runner 必须拒绝,不能悄悄退化成“假成功”。

这种设计的代价是依赖注入和测试装配更复杂,但安全能力不会因节点配置或前端请求而凭空出现。

10. 两道正交的安全门

考虑“质检工位计算调整量,执行工位应用结果”的流程:

1
2
3
4
5
6
flowchart LR
I[Inspection Data] --> B[Station Barrier]
S[Station Ready] --> B
B --> G[Numeric Gate]
A[Adjustment] --> G
G --> R[Controlled Runner]

工位 barrier 检查

  • 所需检测、位姿和调整数据是否属于同一 runId/itemId
  • 设备是否到达指定工位。
  • 必需的握手信号是否在超时内满足。

数值 gate 检查

  • 调整量是否在允许范围。
  • 输入标定和坐标变换是否有效。
  • 决策是 Apply、Skip、Clamp 还是 Reject。

真实 runner 不接受未经 gate 的原始调整量。更稳妥的做法是让类型也表达这一点:

1
2
3
4
5
interface GatedCommand {
decision: "apply" | "clamp";
effectiveOffsetMm: number;
auditId: string;
}

fail-closed 的含义是:runner 缺失、barrier 未满足、gate 拒绝或状态无法确认时,结果都是“不执行真实动作”,同时留下可诊断错误,而不是猜测一个默认值继续。

11. 批量流程为什么要把计算与派发分开

工业视觉不总是拍一帧就立即动作。常见批量流程是:

1
2
3
4
5
6
7
采集多视点
-> 分别检测/配准
-> 按 itemId 映射到目标工艺对象
-> 汇总全部调整
-> 工位 barrier
-> 统一安全检查
-> 批量派发

建议的数据形态:

1
2
3
4
5
6
7
8
9
10
11
interface PlannedAdjustment {
itemId: string;
targetId: string;
offsetMm: number;
sourceFrameId: string;
}

interface AdjustmentBatch {
runId: string;
items: PlannedAdjustment[];
}

计算节点只产生 AdjustmentBatch,不持有 runner。派发节点只接受经过映射、barrier 和 gate 的批次。

这样做可在派发前完成:

  • 预览每个目标对应哪一帧、哪项调整。
  • 检查是否漏项或重复映射。
  • dry-run 比较调整前后的结果。
  • 记录统一审计 trace。

代价是必须定义批次完成条件。若某帧永远不到,需要明确 timeout 后是整批失败、跳过该项还是进入人工确认,不能无限等待。

12. React Flow 编辑器的最小实现边界

第一版编辑器只需要做到六件事:

  1. 从 registry 展示节点面板。
  2. 根据 typed ports 渲染 handles。
  3. 拖线时检查方向和类型。
  4. 用 schema 驱动属性编辑。
  5. 保存、加载完整 FlowDocument + EditorMetadata
  6. 把 trace 映射为节点状态和端口产物摘要。

状态展示应稳定,不因文案或图标改变节点尺寸:

状态 展示
running 高亮边框和当前输入
success 耗时、输出数量和摘要
failed 稳定错误码和错误位置
skipped 降低强调度并说明分支原因

不要在第一版加入多人协作、无限画布优化、自动布局市场、插件商店或真实设备控制。这些都不是验证 Flow 语义的必要条件。

工程验收清单

1. Typed graph editor

交付物:React Flow 节点面板、typed handles、拖线校验、保存加载和后端二次校验。

验收:错误类型不能连接;手改 JSON 后 runtime 仍能拒绝;保存再加载保持端口和配置不变。

面试价值:证明前端交互、schema 建模和执行契约能连成一个系统,而不只是画一张流程图。

2. Streaming frame demo

交付物:模拟相机连续产生 frame handle,检测节点较慢,有界队列提供可切换背压策略。

验收:长时间运行内存不随消息数无限增长;trace 能显示丢弃、等待或采样数量;取消后 source 和下游都停止。

面试价值:证明理解实时数据流、资源上界和可观察性,适合机器人应用软件与工业数据平台方向。

3. Simulate-only safety workflow

交付物:工位 barrier、数值 gate、run-mode 矩阵和审计 trace;runner 使用 mock,默认 simulate。

验收:缺 runner、barrier 未就绪或 gate 拒绝时均 fail-closed;任何测试都不能产生真实 I/O。

面试价值:证明能讨论工业副作用、权限边界和故障模式,而不是把“工作流执行”简化为函数调用。

对转型和面试的价值

这两篇最适合支撑以下定位:

  • 工业数字孪生与机器人可视化工程师。
  • 机器人应用软件工程师。
  • 设备集成、HMI 和工业数据流方向。
  • 工业视觉工作流与 AI 应用工程师。

它利用了已有的 TypeScript、前端交互和系统架构优势,同时补上图校验、调度语义、背压、trace 和工业安全边界。

需要保持职业表述克制:typed graph demo 和 simulate workflow 能证明系统软件思维,但不能证明实时控制、机器人全身控制、真实产线调试或商业机器人交付。要让能力更可信,下一步应补一段可运行演示视频、架构图、失败案例和测试报告。

这篇要解决什么问题

我熟悉 TypeScript、组件状态和异步调用,但第一次看 Flow Graph 时,容易被 DAG、typed port、拓扑排序、barrier 等术语同时淹没。

这篇只解决一个小问题:如何把一组有类型的 TypeScript 函数,组织成一张能在运行前校验、按依赖顺序执行、并留下 trace 的图。

我们用一个虚构的工业视觉流程贯穿全文:

1
启动 -> 模拟采集图像 -> 模拟缺陷检测 -> 计算调整量 -> 安全检查 -> 模拟执行

所有数据都是固定 fixture,不连接相机、PLC 或机器人。最终产物是一个最小批式 DAG 执行器,不是假装已经可用于生产的设备控制系统。

1. 先用熟悉的 TypeScript 理解端口

先不谈图。下面是普通函数调用:

1
2
3
function detect(frame: ImageFrame): DetectionResult {
return { frameId: frame.frameId, defectFound: true, score: 0.92 };
}

可以把 Flow Graph 中的概念理解成:

Flow Graph 熟悉的 TypeScript 概念
node(节点) 函数
input port(输入端口) 有类型的函数参数
output port(输出端口) 有类型的返回值
edge(边) 把一个函数的返回值传给另一个函数

如果 capture 输出 ImageFramedetect 输入也要求 ImageFrame,这条边可以连接。若把 DetectionResult 接到要求 trigger 的端口,就像把对象传给只接收布尔信号的函数,应该在运行前报错。

图比手写函数调用多做四件事:

  1. 校验:端口、类型和必填输入是否正确。
  2. 调度:根据依赖计算执行顺序。
  3. 观察:记录哪个节点成功、失败和耗时。
  4. 持久化:把节点和边保存成 JSON,而不是把流程写死在调用栈中。

2. DAG、数据流图和状态机不是一回事

“都是方框和连线”不代表运行语义相同。

模型 边表示什么 业务数据在哪里 是否允许环
DAG 工作流 先做 A,再做 B 常放在共享 context
Typed dataflow A 的某个输出端口给 B 的某个输入端口 沿有类型的边传递 顶层通常不允许
状态机 满足条件后从状态 A 转到状态 B 状态对象 通常允许

本文做的是 typed dataflow(有类型的数据流图),同时把顶层限制为 DAG(Directed Acyclic Graph,有向无环图)。

判断一张图是否真正表达了 typed dataflow,可以问:

  • 边是否精确连接到端口,而不只是连接节点?
  • 端口是否声明数据类型?
  • 下游是否直接消费上游产物,而不是从全局对象里猜变量名?
  • 图加载时能否发现类型错误和漏接输入?

3. 本文要实现的工业视觉流程

1
2
3
4
5
6
flowchart LR
S[Start] -->|trigger| C[Capture]
C -->|ImageFrame| D[Detect]
D -->|DetectionResult| A[Compute Adjustment]
A -->|Adjustment| G[Safety Check]
G -->|ApprovedAdjustment| E[Simulate Execute]

示例数据固定为:

  • 图像编号:frame-001
  • 缺陷分数:0.92
  • 建议调整量:0.6 mm
  • 允许的最大调整量:2 mm

这里的 safety check 只是解释数据流和 fail-closed(条件不满足就拒绝继续)的最小示例,不代表真实机器人系统只需要比较一个数值。

4. 初始化实验目录

以下代码按 Node.js 24 编写,利用其直接运行可擦除 TypeScript 类型的能力:

1
2
3
4
5
6
mkdir flow-graph-lab
cd flow-graph-lab
npm init -y
npm pkg set type=module
npm install -D typescript @types/node
mkdir -p src/flow

创建 tsconfig.json

1
2
3
4
5
6
7
8
9
10
11
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true
},
"include": ["src/**/*.ts"]
}

相对 import 显式带 .ts 后缀,既能通过上述配置检查,也能由 Node 24 直接执行。

5. 定义图、节点、端口和边

新建 src/flow/types.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
export type PortDataType =
| "trigger"
| "image_frame"
| "detection"
| "adjustment"
| "approved_adjustment";

export interface ImageFrame {
frameId: string;
width: number;
height: number;
}

export interface DetectionResult {
frameId: string;
defectFound: boolean;
score: number;
}

export interface Adjustment {
frameId: string;
offsetMm: number;
}

export interface ApprovedAdjustment extends Adjustment {
approved: true;
}

export interface PortDefinition {
id: string;
dataType: PortDataType;
required?: boolean;
}

export interface FlowNode {
id: string;
type: string;
config: Record<string, unknown>;
}

export interface FlowEdge {
id: string;
from: { node: string; port: string };
to: { node: string; port: string };
}

export interface FlowDocument {
schemaVersion: 1;
id: string;
name: string;
nodes: FlowNode[];
edges: FlowEdge[];
}

export type PortValues = Record<string, unknown>;

export interface RunContext {
runId: string;
signal: AbortSignal;
log(message: string, fields?: Record<string, unknown>): void;
}

export interface NodeDefinition {
type: string;
inputs: PortDefinition[];
outputs: PortDefinition[];
run(
inputs: PortValues,
config: Record<string, unknown>,
context: RunContext,
): Promise<PortValues>;
}

export function isTypeCompatible(
output: PortDataType,
input: PortDataType,
): boolean {
return output === input;
}

这里刻意没有 any。示例规模小,先要求类型完全相等,能更清楚地观察类型校验的价值。

RunContext 只保存运行控制和日志能力,不用来传 ImageFrame 等业务数据。业务数据沿边传递,才能看清来源和去向。

6. 用注册表统一节点契约

图实例只保存节点 ID、节点类型和配置。某种节点有哪些端口、如何运行,由注册表统一定义。

新建 src/flow/registry.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import type { NodeDefinition } from "./types.ts";

export class NodeRegistry {
private readonly definitions = new Map<string, NodeDefinition>();

register(definition: NodeDefinition): this {
if (this.definitions.has(definition.type)) {
throw new Error(`node type already registered: ${definition.type}`);
}
this.definitions.set(definition.type, definition);
return this;
}

get(type: string): NodeDefinition | undefined {
return this.definitions.get(type);
}

require(type: string): NodeDefinition {
const definition = this.get(type);
if (!definition) throw new Error(`unknown node type: ${type}`);
return definition;
}
}

再新建 src/flow/example-nodes.ts。这些节点全部使用固定数据,只用于模拟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
import { NodeRegistry } from "./registry.ts";
import type {
Adjustment,
ApprovedAdjustment,
DetectionResult,
ImageFrame,
NodeDefinition,
} from "./types.ts";

function objectInput(value: unknown, name: string): Record<string, unknown> {
if (typeof value !== "object" || value === null) {
throw new Error(`${name} must be an object`);
}
return value as Record<string, unknown>;
}

const start: NodeDefinition = {
type: "start",
inputs: [],
outputs: [{ id: "trigger", dataType: "trigger" }],
async run() {
return { trigger: true };
},
};

const capture: NodeDefinition = {
type: "capture",
inputs: [{ id: "trigger", dataType: "trigger", required: true }],
outputs: [{ id: "frame", dataType: "image_frame" }],
async run(_inputs, config) {
if (typeof config.frameId !== "string") {
throw new Error("capture.config.frameId must be a string");
}
const frame: ImageFrame = {
frameId: config.frameId,
width: 1280,
height: 1024,
};
return { frame };
},
};

const detect: NodeDefinition = {
type: "detect",
inputs: [{ id: "frame", dataType: "image_frame", required: true }],
outputs: [{ id: "detection", dataType: "detection" }],
async run(inputs, config) {
const frame = objectInput(inputs.frame, "detect.frame");
if (typeof frame.frameId !== "string") {
throw new Error("detect.frame.frameId must be a string");
}
if (typeof config.defectFound !== "boolean" || typeof config.score !== "number") {
throw new Error("detect config is invalid");
}
const detection: DetectionResult = {
frameId: frame.frameId,
defectFound: config.defectFound,
score: config.score,
};
return { detection };
},
};

const computeAdjustment: NodeDefinition = {
type: "compute_adjustment",
inputs: [{ id: "detection", dataType: "detection", required: true }],
outputs: [{ id: "adjustment", dataType: "adjustment" }],
async run(inputs, config) {
const detection = objectInput(inputs.detection, "compute_adjustment.detection");
if (typeof detection.frameId !== "string" || typeof config.offsetMm !== "number") {
throw new Error("compute_adjustment input or config is invalid");
}
const adjustment: Adjustment = {
frameId: detection.frameId,
offsetMm: detection.defectFound === true ? config.offsetMm : 0,
};
return { adjustment };
},
};

const safetyCheck: NodeDefinition = {
type: "safety_check",
inputs: [{ id: "adjustment", dataType: "adjustment", required: true }],
outputs: [{ id: "approved", dataType: "approved_adjustment" }],
async run(inputs, config) {
const adjustment = objectInput(inputs.adjustment, "safety_check.adjustment");
if (typeof adjustment.frameId !== "string" ||
typeof adjustment.offsetMm !== "number" ||
typeof config.maxOffsetMm !== "number") {
throw new Error("safety_check input or config is invalid");
}
if (Math.abs(adjustment.offsetMm) > config.maxOffsetMm) {
throw new Error("adjustment rejected by safety check");
}
const approved: ApprovedAdjustment = {
frameId: adjustment.frameId,
offsetMm: adjustment.offsetMm,
approved: true,
};
return { approved };
},
};

const simulateExecute: NodeDefinition = {
type: "simulate_execute",
inputs: [{ id: "adjustment", dataType: "approved_adjustment", required: true }],
outputs: [],
async run(inputs, _config, context) {
const adjustment = objectInput(inputs.adjustment, "simulate_execute.adjustment");
if (adjustment.approved !== true) {
throw new Error("simulate_execute requires an approved adjustment");
}
context.log("simulation only: no hardware command sent", {
frameId: adjustment.frameId,
offsetMm: adjustment.offsetMm,
});
return {};
},
};

export function createExampleRegistry(): NodeRegistry {
return new NodeRegistry()
.register(start)
.register(capture)
.register(detect)
.register(computeAdjustment)
.register(safetyCheck)
.register(simulateExecute);
}

注册表是契约的单一真源:校验器读取端口,执行器读取 runner,将来编辑器也可以据此生成节点面板。

7. 在运行前校验图

可靠 Flow Graph 的第一价值不是拖线,而是把错误从“设备运行到一半”提前为“图加载失败”。

1
2
3
4
5
6
flowchart LR
A[节点 ID 与类型] --> B[边端点]
B --> C[端口]
C --> D[类型]
D --> E[必填输入]
E --> F[无环]

新建 src/flow/validate.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
import type { NodeRegistry } from "./registry.ts";
import { isTypeCompatible } from "./types.ts";
import type { FlowDocument, PortDefinition } from "./types.ts";

export class GraphValidationError extends Error {
readonly issues: string[];

constructor(issues: string[]) {
super(`invalid flow graph:\n${issues.map((issue) => `- ${issue}`).join("\n")}`);
this.issues = issues;
}
}

function findPort(
ports: PortDefinition[],
portId: string,
): PortDefinition | undefined {
return ports.find((port) => port.id === portId);
}

export function validateGraph(
graph: FlowDocument,
registry: NodeRegistry,
): void {
const issues: string[] = [];
const seenNodeIds = new Set<string>();

for (const node of graph.nodes) {
if (seenNodeIds.has(node.id)) issues.push(`duplicate node id: ${node.id}`);
seenNodeIds.add(node.id);
if (!registry.get(node.type)) issues.push(`unknown node type: ${node.type}`);
}

const nodes = new Map(graph.nodes.map((node) => [node.id, node]));
const incomingCount = new Map<string, number>();

for (const edge of graph.edges) {
const source = nodes.get(edge.from.node);
const target = nodes.get(edge.to.node);

if (!source) {
issues.push(`edge ${edge.id} references missing source node ${edge.from.node}`);
continue;
}
if (!target) {
issues.push(`edge ${edge.id} references missing target node ${edge.to.node}`);
continue;
}

const sourceDefinition = registry.get(source.type);
const targetDefinition = registry.get(target.type);
if (!sourceDefinition || !targetDefinition) continue;

const output = findPort(sourceDefinition.outputs, edge.from.port);
const input = findPort(targetDefinition.inputs, edge.to.port);

if (!output) {
issues.push(`${source.id} has no output port ${edge.from.port}`);
continue;
}
if (!input) {
issues.push(`${target.id} has no input port ${edge.to.port}`);
continue;
}
if (!isTypeCompatible(output.dataType, input.dataType)) {
issues.push(
`type mismatch: ${source.id}.${output.id}(${output.dataType}) -> ` +
`${target.id}.${input.id}(${input.dataType})`,
);
}

const inputKey = `${target.id}.${input.id}`;
incomingCount.set(inputKey, (incomingCount.get(inputKey) ?? 0) + 1);
}

for (const node of graph.nodes) {
const definition = registry.get(node.type);
if (!definition) continue;
for (const input of definition.inputs) {
if (input.required && (incomingCount.get(`${node.id}.${input.id}`) ?? 0) === 0) {
issues.push(`required input is unconnected: ${node.id}.${input.id}`);
}
}
}

issues.push(...detectCycle(graph));

if (issues.length > 0) throw new GraphValidationError(issues);
}

function detectCycle(graph: FlowDocument): string[] {
const indegree = new Map(graph.nodes.map((node) => [node.id, 0]));
const outgoing = new Map(graph.nodes.map((node) => [node.id, [] as string[]]));

for (const edge of graph.edges) {
if (!indegree.has(edge.from.node) || !indegree.has(edge.to.node)) continue;
indegree.set(edge.to.node, (indegree.get(edge.to.node) ?? 0) + 1);
outgoing.get(edge.from.node)?.push(edge.to.node);
}

const queue = graph.nodes
.map((node) => node.id)
.filter((nodeId) => indegree.get(nodeId) === 0);
let visited = 0;

while (queue.length > 0) {
const nodeId = queue.shift();
if (!nodeId) break;
visited += 1;
for (const targetId of outgoing.get(nodeId) ?? []) {
const next = (indegree.get(targetId) ?? 0) - 1;
indegree.set(targetId, next);
if (next === 0) queue.push(targetId);
}
}

return visited === graph.nodes.length
? []
: ["graph contains a cycle"];
}

前端可以在拖线时立即检查类型,改善体验;运行端仍必须重新校验,因为 JSON 也可能来自文件、CLI 或旧版本客户端。

8. 编译为静态执行计划

图只在加载时变化,执行器不必每次都扫描所有边和重复计算顺序。编译阶段完成两件事:

  1. 用 Kahn 算法得到稳定的拓扑顺序。
  2. 把每个节点的入边预先绑定好。
1
2
3
4
5
flowchart LR
G[FlowDocument] --> V[Validate]
V --> T[Topological order]
T --> B[Bind incoming edges]
B --> P[ExecutionPlan]

新建 src/flow/compile.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import type { NodeRegistry } from "./registry.ts";
import type { FlowDocument, FlowEdge } from "./types.ts";
import { validateGraph } from "./validate.ts";

export interface ExecutionPlan {
graph: FlowDocument;
order: string[];
incomingByNode: Map<string, FlowEdge[]>;
}

export function compileGraph(
graph: FlowDocument,
registry: NodeRegistry,
): ExecutionPlan {
validateGraph(graph, registry);

const indegree = new Map(graph.nodes.map((node) => [node.id, 0]));
const outgoing = new Map(graph.nodes.map((node) => [node.id, [] as string[]]));
const incomingByNode = new Map(
graph.nodes.map((node) => [node.id, [] as FlowEdge[]]),
);

for (const edge of graph.edges) {
indegree.set(edge.to.node, (indegree.get(edge.to.node) ?? 0) + 1);
outgoing.get(edge.from.node)?.push(edge.to.node);
incomingByNode.get(edge.to.node)?.push(edge);
}

const queue = graph.nodes
.map((node) => node.id)
.filter((nodeId) => indegree.get(nodeId) === 0);
const order: string[] = [];

while (queue.length > 0) {
const nodeId = queue.shift();
if (!nodeId) break;
order.push(nodeId);
for (const targetId of outgoing.get(nodeId) ?? []) {
const next = (indegree.get(targetId) ?? 0) - 1;
indegree.set(targetId, next);
if (next === 0) queue.push(targetId);
}
}

return { graph, order, incomingByNode };
}

“编辑是 Flow,执行是静态计划”并不矛盾。Flow 是用户看见和保存的语义;静态计划是运行端为了确定性和效率生成的内部结构。

9. 实现确定性执行器

执行器按计划依次运行节点,把上游输出放入目标输入端口,并记录最小 trace。

新建 src/flow/executor.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import type { ExecutionPlan } from "./compile.ts";
import type { NodeRegistry } from "./registry.ts";
import type { PortValues, RunContext } from "./types.ts";

export interface TraceEvent {
nodeId: string;
status: "running" | "success" | "failed";
durationMs?: number;
error?: string;
}

export interface RunResult {
outputs: Map<string, unknown>;
trace: TraceEvent[];
}

function outputKey(nodeId: string, portId: string): string {
return `${nodeId}.${portId}`;
}

export class FlowExecutor {
private readonly plan: ExecutionPlan;
private readonly registry: NodeRegistry;

constructor(
plan: ExecutionPlan,
registry: NodeRegistry,
) {
this.plan = plan;
this.registry = registry;
}

async run(context: RunContext): Promise<RunResult> {
const nodes = new Map(this.plan.graph.nodes.map((node) => [node.id, node]));
const outputs = new Map<string, unknown>();
const trace: TraceEvent[] = [];

for (const nodeId of this.plan.order) {
if (context.signal.aborted) throw new Error(`run ${context.runId} cancelled`);

const node = nodes.get(nodeId);
if (!node) throw new Error(`compiled node is missing: ${nodeId}`);
const definition = this.registry.require(node.type);
const inputs: PortValues = {};

for (const edge of this.plan.incomingByNode.get(nodeId) ?? []) {
const key = outputKey(edge.from.node, edge.from.port);
if (!outputs.has(key)) throw new Error(`upstream output is missing: ${key}`);
inputs[edge.to.port] = outputs.get(key);
}

const startedAt = performance.now();
trace.push({ nodeId, status: "running" });

try {
const result = await definition.run(inputs, node.config, context);
for (const port of definition.outputs) {
if (!(port.id in result)) {
throw new Error(`declared output is missing: ${nodeId}.${port.id}`);
}
outputs.set(outputKey(nodeId, port.id), result[port.id]);
}
trace.push({
nodeId,
status: "success",
durationMs: performance.now() - startedAt,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
trace.push({
nodeId,
status: "failed",
durationMs: performance.now() - startedAt,
error: message,
});
throw new Error(`node ${nodeId} failed: ${message}`, { cause: error });
}
}

return { outputs, trace };
}
}

这个版本刻意顺序执行。先把输入收集、错误传播和结果可重复做正确,再讨论并行与流式,否则很难判断错误来自基础语义还是并发调度。

10. 运行完整示例

新建 src/main.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { compileGraph } from "./flow/compile.ts";
import { createExampleRegistry } from "./flow/example-nodes.ts";
import { FlowExecutor } from "./flow/executor.ts";
import type { FlowDocument, RunContext } from "./flow/types.ts";

const graph: FlowDocument = {
schemaVersion: 1,
id: "vision-inspection-demo",
name: "Vision Inspection Demo",
nodes: [
{ id: "start", type: "start", config: {} },
{ id: "capture", type: "capture", config: { frameId: "frame-001" } },
{ id: "detect", type: "detect", config: { defectFound: true, score: 0.92 } },
{ id: "adjust", type: "compute_adjustment", config: { offsetMm: 0.6 } },
{ id: "gate", type: "safety_check", config: { maxOffsetMm: 2 } },
{ id: "execute", type: "simulate_execute", config: {} },
],
edges: [
{ id: "e1", from: { node: "start", port: "trigger" }, to: { node: "capture", port: "trigger" } },
{ id: "e2", from: { node: "capture", port: "frame" }, to: { node: "detect", port: "frame" } },
{ id: "e3", from: { node: "detect", port: "detection" }, to: { node: "adjust", port: "detection" } },
{ id: "e4", from: { node: "adjust", port: "adjustment" }, to: { node: "gate", port: "adjustment" } },
{ id: "e5", from: { node: "gate", port: "approved" }, to: { node: "execute", port: "adjustment" } },
],
};

const registry = createExampleRegistry();
const executor = new FlowExecutor(compileGraph(graph, registry), registry);
const context: RunContext = {
runId: "run-001",
signal: new AbortController().signal,
log(message, fields) {
console.log(message, fields ?? {});
},
};

const result = await executor.run(context);
console.log("detection", result.outputs.get("detect.detection"));
console.log("approved", result.outputs.get("gate.approved"));
console.log(
"order",
result.trace
.filter((event) => event.status === "success")
.map((event) => event.nodeId),
);

运行:

1
node src/main.ts

输出重点如下:

1
2
3
4
simulation only: no hardware command sent { frameId: 'frame-001', offsetMm: 0.6 }
detection { frameId: 'frame-001', defectFound: true, score: 0.92 }
approved { frameId: 'frame-001', offsetMm: 0.6, approved: true }
order [ 'start', 'capture', 'detect', 'adjust', 'gate', 'execute' ]

11. 用测试锁定行为

新建 src/flow/flow.test.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
import assert from "node:assert/strict";
import test from "node:test";
import { compileGraph } from "./compile.ts";
import { createExampleRegistry } from "./example-nodes.ts";
import { FlowExecutor } from "./executor.ts";
import { NodeRegistry } from "./registry.ts";
import type { FlowDocument, NodeDefinition, RunContext } from "./types.ts";
import { GraphValidationError, validateGraph } from "./validate.ts";

function createGraph(): FlowDocument {
return {
schemaVersion: 1,
id: "vision-inspection-demo",
name: "Vision Inspection Demo",
nodes: [
{ id: "start", type: "start", config: {} },
{ id: "capture", type: "capture", config: { frameId: "frame-001" } },
{ id: "detect", type: "detect", config: { defectFound: true, score: 0.92 } },
{ id: "adjust", type: "compute_adjustment", config: { offsetMm: 0.6 } },
{ id: "gate", type: "safety_check", config: { maxOffsetMm: 2 } },
{ id: "execute", type: "simulate_execute", config: {} },
],
edges: [
{ id: "e1", from: { node: "start", port: "trigger" }, to: { node: "capture", port: "trigger" } },
{ id: "e2", from: { node: "capture", port: "frame" }, to: { node: "detect", port: "frame" } },
{ id: "e3", from: { node: "detect", port: "detection" }, to: { node: "adjust", port: "detection" } },
{ id: "e4", from: { node: "adjust", port: "adjustment" }, to: { node: "gate", port: "adjustment" } },
{ id: "e5", from: { node: "gate", port: "approved" }, to: { node: "execute", port: "adjustment" } },
],
};
}

test("executes the industrial vision flow in deterministic order", async () => {
const registry = createExampleRegistry();
const plan = compileGraph(createGraph(), registry);
const executor = new FlowExecutor(plan, registry);
const logs: string[] = [];
const context: RunContext = {
runId: "run-001",
signal: new AbortController().signal,
log(message) { logs.push(message); },
};

const result = await executor.run(context);

assert.deepEqual(
result.trace.filter((event) => event.status === "success").map((event) => event.nodeId),
["start", "capture", "detect", "adjust", "gate", "execute"],
);
assert.deepEqual(result.outputs.get("detect.detection"), {
frameId: "frame-001",
defectFound: true,
score: 0.92,
});
assert.deepEqual(result.outputs.get("gate.approved"), {
frameId: "frame-001",
offsetMm: 0.6,
approved: true,
});
assert.deepEqual(logs, ["simulation only: no hardware command sent"]);
});

test("rejects incompatible port types", () => {
const graph = createGraph();
graph.edges[1] = {
id: "bad-type",
from: { node: "detect", port: "detection" },
to: { node: "capture", port: "trigger" },
};

assert.throws(
() => validateGraph(graph, createExampleRegistry()),
(error) => error instanceof GraphValidationError &&
error.issues.some((issue) => issue.includes("type mismatch")),
);
});

test("rejects an unconnected required input", () => {
const graph = createGraph();
graph.edges = graph.edges.filter((edge) => edge.to.node !== "detect");

assert.throws(
() => validateGraph(graph, createExampleRegistry()),
(error) => error instanceof GraphValidationError &&
error.issues.includes("required input is unconnected: detect.frame"),
);
});

test("rejects an unknown port", () => {
const graph = createGraph();
graph.edges[1] = {
id: "bad-port",
from: { node: "capture", port: "missing" },
to: { node: "detect", port: "frame" },
};

assert.throws(
() => validateGraph(graph, createExampleRegistry()),
(error) => error instanceof GraphValidationError &&
error.issues.includes("capture has no output port missing"),
);
});

test("rejects a cycle", () => {
const loop: NodeDefinition = {
type: "loop",
inputs: [{ id: "in", dataType: "trigger", required: true }],
outputs: [{ id: "out", dataType: "trigger" }],
async run() { return { out: true }; },
};
const registry = new NodeRegistry().register(loop);
const graph: FlowDocument = {
schemaVersion: 1,
id: "cycle",
name: "Cycle",
nodes: [
{ id: "a", type: "loop", config: {} },
{ id: "b", type: "loop", config: {} },
],
edges: [
{ id: "ab", from: { node: "a", port: "out" }, to: { node: "b", port: "in" } },
{ id: "ba", from: { node: "b", port: "out" }, to: { node: "a", port: "in" } },
],
};

assert.throws(
() => validateGraph(graph, registry),
(error) => error instanceof GraphValidationError &&
error.issues.some((issue) => issue.includes("cycle")),
);
});

运行类型检查和测试:

1
2
npx tsc
node src/flow/flow.test.ts

五个测试分别证明:正常链路顺序稳定、类型错误会被拒绝、必填输入不能漏接、端口名不能写错、顶层图不能有环。

12. 当前版本没有解决什么

这个执行器已经形成 schema -> validate -> compile -> execute -> trace 闭环,但它仍然只是最小批式 DAG:

  • 没有 branch 的 skipped 语义。
  • 没有并行执行和 fan-in barrier 的运行时状态。
  • 没有多消息流、有界队列和 backpressure(背压)。
  • 没有 timeout、retry 和真实 I/O 的取消传播。
  • 没有运行时 payload schema,只校验端口声明的类型名称。
  • 没有任何真实设备能力。

这些不是“少写几段代码”的问题,而是需要先定义清楚运行语义。下篇会逐项拆开。

验收与下一步

完成本文后,应能独立回答:

  1. typed edge 相比共享黑板解决了什么问题?
  2. 为什么前端拖线校验不能替代运行端校验?
  3. 为什么图要先编译再反复运行?
  4. RunContext 为什么不应该承载主要业务数据?
  5. 当前执行器为什么还不能连接真实机器人?

练习:

  1. 增加重复 edge ID 校验,并写一个失败测试。
  2. 增加无硬件能力的 manual_review 节点,输出人工确认结果。
  3. ImageFrameDetectionResult 增加运行时 payload 校验。

这份最小实现能证明 typed dataflow、图校验和确定性调度的基础能力。它对工业可视化、机器人应用软件和系统集成岗位有价值,但不证明真实设备调试或运动控制能力。

下一篇:Flow Graph 进阶:可视化、流式执行与工业安全

AI Agent 已经不只是“模型加几个工具”。真正拉开实际效果差距的,往往是任务怎样拆分、上下文怎样隔离、工具怎样授权、状态怎样持久化,以及失败后怎样验证和恢复。

这篇文章不是产品大全,也不试图追完所有论文。它更像一份工作手册:遇到调研、编码、业务自动化、工业软件或机器人任务时,先判断应该采用哪种工作模式,再选择合适的 Harness、Skills、协议和运行框架。

最后核对:2026-07-22

一、先看结论:按任务选择工作模式

任务特征 首选模式 适合考虑的工具 何时升级 不要先做什么
目标清楚,主要难点是跨文件、搜索和工具推理 单 Agent + 动态工具 Codex、Claude Code、KimiCode、OpenHands 上下文互相污染,或出现可独立验收的调查面 不要仅为“更智能”增加 Agent 数量
团队反复执行同一类工程流程 Skills-driven Agent Agent Skills、Superpowers、Matt Pocock Skills、仓库级自定义 Skill 流程开始需要持久状态、审批或复杂分支 不要把 Skill 当成可执行服务或状态机
有少量边界清楚、依赖较少的子任务 Subagents Codex/Claude 的子任务能力、OpenAI Agents SDK、Claude Agent SDK 子任务数量动态变化、任务宽度显著增大 不要让多个 Agent 同时改同一文件
搜索空间很宽,分片同构且并行收益可测 Agent Swarm Kimi Agent Swarm、自建 orchestrator-worker 已能定义去重、聚合、停止条件和预算 不要用于串行依赖链、精确事务或设备控制
步骤、分支、重试和审计要求明确 确定性 Workflow + 局部 Agent LangGraph、Google ADK、Microsoft Agent Framework、Temporal 某些节点必须处理非结构化材料和开放判断 不要让 LLM 自由改写业务状态机
任务运行数小时或数天,需要暂停与恢复 后台长任务 Durable workflow、队列、checkpoint、Codex/Claude/Kimi 的后台能力 出现发布、付费、删除、设备写入等风险边界 不要依赖一个不断增长的聊天上下文
错误代价高,涉及权限升级或主观取舍 Human-in-the-loop 审批 UI、策略引擎、审计日志、受限工具 同类低风险动作已有可量化且预授权的规则 不要把“人工点确认”误认为完整安全设计

推荐的复杂度升级顺序是:

1
2
3
4
5
6
强单 Agent 基线
-> 把重复做法提炼为 Skills
-> 为上下文隔离引入少量 subagents
-> 为稳定分支引入确定性 workflow
-> 为长任务增加持久化与恢复
-> 只有任务宽度和并行收益可测时才使用 swarm

多 Agent 不是默认升级。它只有在任务可分解、子结果可验收、协调成本低于收益时才值得使用。

二、从 Chat、Agent 到 Agent Team

普通 Chat 的核心是生成回答;Agent 的核心是形成“观察环境、选择动作、读取结果、继续或停止”的循环。Agent Team 则进一步增加委派、隔离、同步、仲裁和聚合。

这几个概念经常被混在一起,可以用下面的分层理解:

1
2
3
4
5
6
7
Model
-> Agent loop / reasoning policy
-> Skills and instructions
-> Harness and runtime
-> MCP / native tools
-> A2A
-> Multi-agent workflow

这不是强制调用栈,而是一张选型地图:

层级 主要回答的问题 不能据此推出
Model 下一段内容或工具调用怎样生成 模型分数不能单独证明端到端任务成功
Agent loop 何时调用工具、观察、重试和停止 有循环不等于有权限、恢复和可靠重试
Skills 某类任务的流程知识怎样按需注入 Skill 不是工具协议,也不是执行沙箱
Harness 模型、上下文、工具、权限和验证怎样装配 同一模型换 Harness 可能得到不同结果
MCP / native tools Agent 怎样读取数据、调用动作 MCP 解决连接,不决定任务拆分策略
A2A 独立 Agent 服务怎样发现、委派和回传 A2A 不是内部规划算法,也不替代工具权限
Multi-agent workflow 多个 Agent 怎样分工、同步和收敛 Agent 数量多不自动带来质量或速度

因此,“某产品支持 MCP”不等于它具备多智能体协作;“写了一组角色提示”也不等于得到了可恢复的工作流。

三、六类值得掌握的工作模式

1. Skills-driven Agent:把经验变成可复用流程

Skills 替代的是散落在 Wiki、聊天记录和个人记忆中的 SOP。它适合已经反复出现、步骤和验收逐渐稳定,但每次输入仍不同的任务。

一个实用 Skill 通常包含:

  • 何时触发,以及何时不触发;
  • 固定的检查顺序和禁止动作;
  • 需要读取的参考资料、脚本或模板;
  • 产物格式和验收命令;
  • 失败时停止、升级或交给人工的条件。

Skill 本身不会创建并行,也不会提供凭证、持久状态和事务恢复。它编码的是流程知识,不是运行时。

最小试验:把“ROS2 故障信息采集”做成一个 Skill,只规定 topic、TF、日志和参数的只读检查顺序,在两个不同故障上比较漏项数、上下文用量和结论可复现性。

2. Subagents:用隔离上下文处理少量独立子任务

Subagent 适合少量、边界清楚、可独立交付摘要或 patch 的任务。例如主 Agent 负责整体功能,子 Agent 分别调查 API 契约、前端实现位置和测试策略。

关键不是给它们起“架构师”“批评家”的名字,而是明确:

  • 输入范围;
  • 文件所有权;
  • 输出契约;
  • 局部验收;
  • 与其他子任务的依赖。

只读调查和不同文件所有权可以并行;共享接口应先约定;同一文件最好维持单写者。子 Agent 的同意不能替代测试,汇总后仍要运行跨模块验证。

最小试验:把一个小功能拆成“接口核对、UI 实现、测试设计”三个任务,与单 Agent 对照总 token、墙钟时间、冲突数和最终测试结果。

3. Agent Swarm:让宽搜索空间动态扩张

Swarm 适合大量或数量未知的同构分片,例如批量扫描文档、并行搜索候选、对大量文件抽取相同字段。它的核心不只是“并发更多”,而是协调者可以动态派生任务,并通过共享 artifact 聚合结果。

Swarm 的前置条件比演示中看起来更严格:

  • 有明确的分片键和去重规则;
  • 每个分片可以独立失败和重试;
  • 聚合器能验证覆盖率和引用;
  • 有最大深度、并发、工具调用、费用和超时限制;
  • 有强单 Agent 基线作为对照。

Kimi 在 2026 年 2 月的 Agent Swarm 技术博客中披露:基于当时 K2.5 的系统可部署最多 100 个 subagents、执行超过 1,500 次工具调用,并在其测试场景中相对串行执行快 4.5 倍。这是厂商对特定系统和任务的披露,不是独立通用基准,也不能直接归因给 2026 年 7 月发布的 K3。

最小试验:对 30 份设备手册并行提取故障码,最多使用 5 个工作 Agent 和固定工具预算,与单 Agent 比较字段召回、错误引用、总成本和墙钟时间。

4. 确定性 Workflow:让 LLM 只处理不确定节点

当步骤、状态转移、重试、幂等和审计能够提前定义时,应让普通软件控制流程,让 Agent 只处理分类、抽取、检索或候选生成等非结构化节点。

例如:

1
2
3
4
5
6
7
告警进入
-> 拉取设备上下文
-> Agent 生成归因候选
-> 规则校验
-> 人工批准
-> 确定性执行器创建工单
-> 记录结果与审计

这类架构比“让 Agent 自己决定下一步”更容易做状态测试、故障注入、重试和补偿。LangGraph、Google ADK、Microsoft Agent Framework 可用于表达 Agent workflow;Temporal 等持久工作流系统则适合承载更严格的长任务恢复,但 Temporal 本身不是 Agent 框架。

最小试验:实现五步告警流程,模拟超时、重复消息和进程重启,验证不会重复建单,并能从中断点恢复。

5. 后台长任务:把状态从聊天窗口移出去

跨分钟、小时或天的任务,不能只依赖一个持续增长的上下文窗口。计划、检查点、artifact、权限租约、事件日志和预算应该外置;每次唤醒只重建最小上下文。

长任务至少要具备:

  • 可查询进度;
  • 可取消、暂停和恢复;
  • 心跳、租约和超时;
  • 幂等键与重复投递处理;
  • artifact 和原始证据可追溯;
  • 外部环境或仓库版本漂移检测。

最小试验:让后台 Agent 每小时读取一次模拟设备日志并更新报告,运行 8 小时,中途杀死 worker,验证恢复后没有重复告警。

6. Human-in-the-loop:在具体动作前设置治理闸门

人工审批适合发布、付费、删除、外部通信、权限升级、设备写入,以及超出预授权风险包络的动作。审批界面应展示具体目标、参数、证据、diff、影响范围和回滚方案,而不是让人对一句模糊意图点“同意”。

已经过风险评估、位于预授权确定性流程内、受限额和监控约束的低风险动作,可以按策略自动执行。闸门过多会制造审批疲劳。

必须强调:人工审批是治理措施,不是功能安全措施,不能替代安全 PLC、安全控制器、参数限制、联锁、风险评估和适用标准。

四、复杂任务应该怎样拆

高质量拆分不是生成一份自然语言待办列表,而是建立一张可执行、可验证的任务图。

1. 先画依赖,再谈并行

每个节点至少写清:

1
2
3
4
5
6
7
8
目标
输入与允许读取的上下文
输出 artifact
文件或资源所有权
依赖节点
验收条件
预算与超时
失败后的重试、降级或升级路径

任务图中“已经就绪且互不冲突的节点数”只是结构上的并行上限。实际并发还会被 API 限流、token 预算、工具资源、文件冲突和协调成本进一步压缩。

2. 上下文按职责分区

不要把完整仓库、全部聊天历史和所有工具权限广播给每个 Agent。主 Agent 保存全局目标和依赖,子 Agent 只获得完成任务所需的最小材料;共享事实落入文件、数据库或 artifact,而不是依赖转述记忆。

3. 单写者,多个读者

多 Agent 编码最常见的失败不是不会写代码,而是同时修改共享文件。可以并行搜索、阅读和独立模块实现,但共享接口与高冲突文件应由一个 Agent 写入,其他 Agent 返回建议或 patch 草案。

4. 验收器必须提供独立证据

好的验收器包括测试、类型检查、schema 校验、截图对比、查询结果、仿真输出和人工检查。另一个 LLM reviewer 可以发现问题,但“两个 Agent 都认为正确”并不是独立证据。

5. 重规划应该由事件触发

只有依赖变化、验收失败、预算超限、外部资源不可用或新证据推翻假设时才重规划。每一步都重新生成全局计划,会浪费上下文,也容易让目标漂移。

五、Skills:Superpowers、Matt Pocock与方法论复用

Agent Skills Specification正在形成一种可移植的目录约定:入口 SKILL.md 描述触发条件和流程,脚本、模板、参考资料按需加载。它的重要价值是渐进式上下文,而不是把所有知识塞进系统提示。

Superpowers

obra/superpowers把 brainstorming、计划、TDD、调试、代码审查、验证和分支收尾做成强约束的流程 Skills。它适合需要提高工程纪律、减少 Agent 跳步的任务,尤其适合已有测试和 Git 工作流的代码库。

它的限制也很明确:

  • 流程有额外调用和审查成本;
  • 依赖宿主 Agent 真正遵守 Skill;
  • 对一次性小改动可能显得过重;
  • Skill 不能弥补错误的验收标准或缺失的测试环境。

对我更有价值的用法,不是完整照搬所有流程,而是挑出“先复现、隔离工作树、规格审查、质量审查、完成前验证”这些高收益约束。

Matt Pocock Skills

mattpocock/skills更像一组可阅读、可改写的编码技能样例。它的价值在于展示如何把领域经验拆成清晰的触发条件、步骤和检查表,适合学习 TypeScript、前端和编码 Agent 的 Skill 写法。

它不是权威标准,也没有统一的跨模型效果基准。个人维护项目的 star、传播量和使用体验只能作为线索,不能证明在自己的仓库里一定有效。

何时自己写 Skill

出现下面三个信号时值得提炼:

  • 同类任务反复发生,人工每次都在补充相同约束;
  • 成败依赖固定检查顺序或遗漏防护;
  • 产物可以通过命令、schema、截图或清单稳定验收。

建议优先写与自己方向直接相关的 Skills:ROS2 故障采集、URDF/TF 检查、工业协议联调记录、前端视觉回归、Agent 工具权限审计。

六、Harness:模型之外真正决定效果的系统

Harness 是模型实际工作的运行环境。它决定:

  • 能看到哪些仓库规则和上下文;
  • 如何搜索、编辑、运行命令和浏览页面;
  • 工具调用怎样授权和隔离;
  • 是否支持 Skills、subagents、后台任务和工作树;
  • 如何压缩上下文、保存 artifact、运行测试和呈现 diff;
  • 失败后能否恢复、取消和审计。

因此同一个模型放在 Codex、Claude Code、KimiCode 或自建 CLI runtime 中,最终效果可能明显不同。Kimi K3 技术页本身也展示了不同评测会使用 KimiCode、Claude Code 或 Codex 等 Harness,这说明成绩不能简单归因于“裸模型”。

评测一个 coding Harness 时,至少锁定:模型版本、Harness 版本、工具集、权限、推理预算、超时、并发、数据集版本、重试次数和验收命令。

对个人开发者,Harness 的优先级通常高于从零自建 Agent 框架。先在真实仓库中比较 Codex、Claude Code、KimiCode 或 OpenHands 的任务通过率、人工返工和工具轨迹,再决定是否需要下沉到 SDK。

七、MCP、A2A与Agent Skills怎样组合

MCP:连接工具和数据

Model Context Protocol定义客户端怎样发现并调用 tools、resources 和 prompts。它适合把数据库、代码托管、文档、浏览器或内部服务以统一方式暴露给 Agent。

MCP 不负责:

  • 怎样拆分复杂任务;
  • 多个 Agent 怎样协调;
  • server 是否可信;
  • 业务权限是否正确;
  • 调用结果是否真实或安全。

MCP Registry提供发现入口,但“被收录”不等于安全和质量背书。安装第三方 server 前仍要固定版本、审查代码、隔离凭证并授予最小权限。

A2A:连接独立 Agent 系统

A2A 1.0关注独立 Agent 服务之间的发现、任务委派、消息、artifact 和异步协作。它更适合跨团队、跨产品或跨部署域,而不是一个进程内部的简单 subagent。

协议互通只证明消息可以交换。身份、授权委托、租户隔离、参数校验、速率限制、审计和对端信任仍需要应用层设计。

一种实用组合

1
2
3
4
5
Agent Skills:告诉 Agent 应该怎样完成某类任务
Harness:装配模型、上下文、工具、权限和验证
MCP:让 Agent 访问外部工具与数据
A2A:让独立 Agent 服务互相委派与回传
Workflow:管理状态、依赖、重试、审批和收敛

小团队通常先需要 Skills、Harness 和少量 MCP。只有出现独立 Agent 服务、组织边界或异步任务契约时,A2A 才值得进入架构。

八、工具地图:不要按厂商堆功能

日常编码 Harness

工具 现在值得用在哪里 不适合 建议
Codex 跨文件修改、测试、审查、迁移、仓库内委派 业务 Agent 后端、实时控制、无人监管发布 作为当前主力,积累任务通过率和 Skills
Claude Code 长上下文仓库理解、终端编码、subagents 严格厂商无关 runtime 用同一组仓库任务与 Codex 做对照
KimiCode 中文团队编码、终端与视觉反馈、长程试验 把产品演示当稳定自治证明 纳入对照试验,单独记录 K3、KimiCode 和 Swarm
OpenHands 需要开源 runtime、可审计环境或自托管试验 希望零运维直接使用 在隔离仓库复现一组固定任务后再投入部署

Agent 与 Workflow 框架

工具 强项 适合什么时候选择
OpenAI Agents SDK manager-as-tools、handoff、guardrails、trace 已使用 OpenAI API,需要轻量多 Agent 编排
Claude Agent SDK 独立上下文、工具权限和 subagent 配置 想把 Claude Code 式 Agent loop 嵌入产品
Google ADK workflow agents、并行与层级团队 Google 生态或需要显式工作流组合
LangGraph 有状态图、checkpoint、恢复和人工介入 复杂业务流程、长任务和可观测状态机
Microsoft Agent Framework 企业 Agent 与 workflow 统一方向 已在 Azure、Semantic Kernel 或 AutoGen 生态
LlamaIndex 数据、检索与 AgentWorkflow 核心难点是企业数据和知识工作流
CrewAI 角色、任务和 crew 抽象直观 原型和教学;生产前必须补状态、重试与评测
Temporal 持久执行、事件历史、重试和恢复 长任务的外层运行保障;它不是 Agent 框架

不要因为框架支持的 Agent 类型最多就选它。对一个可在单进程内完成的小工具,SDK 和 runtime 的学习、部署、观测成本可能比模型调用本身更高。

可观测与评测

Langfuse、OpenTelemetry GenAI 语义、框架自带 tracing 可帮助定位失败发生在检索、推理、工具选择还是执行阶段。但 trace 不是正确性证明,也可能包含 prompt、凭证、代码和设备数据,必须做脱敏和访问控制。

公开 benchmark 也要按任务使用:SWE-bench 看仓库修复,Terminal-Bench 看终端长任务,GAIA 和 BrowseComp 看检索研究,tau-bench 看带规则的工具交互,OSWorld 看 GUI 操作,BFCL 看函数调用。分数只有在版本、Harness、预算和工具条件一致时才可比较。

九、六类场景手册

1. 调研与知识工作

首选模式: 强单 Agent + 动态检索 + 来源台账。材料数量很大、可按主题或文档独立分片时,再增加 subagents;只有分片宽度和聚合指标明确时才试 swarm。

候选工具: Codex/Claude/Kimi 的研究与文件工具、OpenAI Agents SDK、LlamaIndex、浏览器工具、MCP 数据源。

检查项: 来源日期、证据等级、原文链接、重复来源、版本归因、厂商声明与独立评测分离。

最小试验: 调研 20 个同类工具,要求每个结论回链一手来源;比较单 Agent 与 4 个主题 subagents 的覆盖率、错误引用、成本和汇总返工。

2. 编码与大型仓库

首选模式: coding Harness + 仓库规则 + 测试闭环。单仓库修改保持单写者;跨模块只在文件所有权清楚时并行;跨仓库迁移使用任务图和检查点。

候选工具: Codex、Claude Code、KimiCode、OpenHands、Superpowers、自定义 Skills。

检查项: 复现命令、允许修改范围、接口契约、测试与类型检查、工作树隔离、diff 审查、回滚方式。

最小试验: 选择两组难度匹配的真实 bug,在干净基线中交换工具执行顺序,比较一次通过率、人工干预、工具调用和测试覆盖,避免学习效应造成偏差。

3. 产品设计与前端实现

首选模式: 单 Agent 负责整体一致性,视觉素材、设计核对和无重叠组件可并行调查。使用截图、浏览器交互和像素检查作为独立反馈。

候选工具: coding Harness、浏览器自动化、Figma/设计系统连接、图像生成、视觉回归工具。

检查项: 目标用户、既有设计系统、桌面与移动视口、真实交互状态、文本溢出、可访问性、截图验收。

最小试验: 实现一个机器人状态面板,在两个视口运行交互和截图检查;比较仅看代码与加入视觉闭环后的返工项。

4. 运维与业务自动化

首选模式: 确定性 workflow 包围受限 Agent。Agent 负责读日志、分类、提取和提出动作;普通软件负责幂等、限额、重试、补偿、审批和审计。

候选工具: LangGraph、Temporal、Google ADK、Microsoft Agent Framework、MCP、策略与审批系统。

检查项: 最小权限、凭证隔离、dry-run、幂等键、超时、回滚、人工闸门、审计、prompt injection 和数据外泄。

最小试验: 在 mock 工单系统中处理告警,只允许 Agent 生成分类和工单草案;模拟重复消息、API 超时和恶意日志文本。

5. 工业软件与设备集成

首选模式: 设备状态模型和确定性状态机在核心层,Agent 作为只读解释、资料检索和候选诊断节点。低风险、预授权动作也应经过白名单、参数范围和状态前置条件。

候选工具: 普通 workflow runtime、MQTT/OPC UA/Modbus 适配器、时序与事件存储、LangGraph/Agents SDK、只读 MCP server。

检查项: 设备身份、状态新鲜度、数据质量、命令与反馈关联、离线/重连、重放、告警风暴、权限和审计。

最小试验: 建立 Modbus/OPC UA/MQTT 模拟设备平台,Agent 只读事件日志并生成诊断,禁止直接写设备;通过重放比较诊断一致性。

6. 机器人应用与AI辅助运维

首选模式: Agent 位于意图解析、手册检索、候选计划、工具编排和解释层;ROS2 action、行为树、规划器、普通控制软件和安全控制系统负责可验证执行。

1
2
3
4
5
6
7
ROS2 / 设备状态
-> 确定性采集与状态模型
-> 受限 Agent + 排障 Skill
-> 证据、命令草案、影响范围
-> 策略校验与必要的人工批准
-> 确定性执行器
-> 反馈、审计与独立安全系统

候选工具: ROS2 Jazzy、仿真器、行为树、Codex/Claude/Kimi 作为开发 Harness、Agents SDK/LangGraph 作为非实时编排层、只读 MCP 工具。

检查项: frame 与时间戳、机器人和环境状态、命令白名单、速度/区域/参数限制、仿真验证、操作者权限、动作反馈、急停和安全系统独立性。

最小试验: 做一个只连接仿真或只读 ROS2 状态的 Robot Operations Assistant。它可以解释告警和生成命令草案,但不能进入实时控制回路,也不能绕过规划器、联锁或安全控制器。

十、工具选型检查清单

在引入一个新框架、协议或多 Agent 方案前,逐项回答:

维度 必须问的问题
任务结构 任务真的可独立拆分吗?依赖和共享状态在哪里?
单 Agent 基线 单 Agent + 更好上下文、工具和 Skill 是否已经足够?
上下文 谁拥有全局信息?子任务拿到哪些最小材料?摘要会丢什么?
并行 墙钟时间是否重要?并发会不会产生文件、数据库或 API 冲突?
成本 总 token、工具调用、外部 API、存储和人工审查成本是多少?
权限 每个 Agent、Skill、MCP server 能读写什么?凭证怎样隔离?
状态与恢复 进程终止、超时、重复投递、环境漂移后怎样恢复?
验证 哪些测试、schema、截图、仿真或人工证据能判定完成?
可观测 能否看到任务图、trace、预算、失败分片和实际副作用?
绑定 模型、云平台、SDK、托管 runtime 和私有协议的迁移成本是什么?
安全 prompt injection、工具滥用、记忆污染和供应链风险怎样控制?
停止条件 何时成功、何时降级、何时交给人工、何时取消?

如果这些问题无法回答,多 Agent 通常只会把不确定性放大。

十一、常见失败模式

1. 伪并行

把强依赖步骤同时派发,最后仍要等待或返工;多个 Agent 同时编辑共享文件,合并成本超过节省的时间。

2. 角色很多,证据相同

同一模型、同一上下文、同一检索源,仅靠不同角色提示产生的意见高度相关。需要独立性时,更值得测试不同数据切片、工具、模型或确定性验证器,但目前“异构一定更好”仍缺少足够广泛的外部验证。

3. 上下文广播与摘要损失

全量广播导致 token 和权限膨胀;过度摘要又会丢失跨域约束。应保留原始 artifact 引用,并让验收节点能够回查证据。

4. 把模型错误变成系统副作用

没有参数校验、最小权限、dry-run、幂等和回滚时,一次错误工具调用就可能产生真实损失。MCP、A2A 或框架自带 guardrail 都不能自动建立业务安全边界。

5. 验证器与生成器共享错误规格

测试、LLM judge、critic 和仿真都可能验证了错误目标。高风险任务需要不同性质的证据,并保留人工或现场验证。

6. 把长对话当持久状态

上下文压缩会丢信息,外部环境会变化,权限也会过期。长任务必须将状态、事件和 artifact 外置。

7. 只看榜单,不看 Harness

模型分数可能来自不同工具、并发、预算、超时和重试策略。公开 benchmark 也不代表自己的代码库、业务流程或设备环境。

8. 过度自治

能调用工具不等于应该自动执行。尤其在工业和机器人系统中,LLM 不应进入实时控制或功能安全回路;人工批准也不能替代独立安全设计。

十二、我的试用顺序与持续观察渠道

结合现有 Web/full-stack、可视化和 AI 应用经验,最有价值的路线不是先搭一个庞大的多 Agent 平台,而是逐层证明工程能力。

第一阶段:把现有 coding Agent 用扎实

  1. 用 Codex 作为主力 Harness,固定复现、测试、diff 和完成前验证流程。
  2. 提炼 3 个高频 Skills:仓库诊断、前端视觉验收、技术调研来源审计。
  3. 用同一组真实任务对照 Claude Code、KimiCode 或 OpenHands,记录成功率、返工、成本和工具轨迹。

第二阶段:增加 MCP 与少量 subagents

  1. 先连接只读、低风险的数据源,例如文档、Git 仓库或模拟设备状态。
  2. 用 2 到 4 个边界清晰的 subagents 做调研和分文件任务。
  3. 建立单写者、任务契约、局部验收和最终集成测试。

第三阶段:做可恢复、可观测的 workflow

  1. 用 LangGraph、ADK 或 Agents SDK 做“确定性流程 + Agent 节点”。
  2. 引入 trace、数据集评测、checkpoint、幂等和故障注入。
  3. 任务确实跨小时并需要强恢复时,再评估 Temporal 等持久工作流系统。

第四阶段:连接机器人与工业场景

最值得做的作品是一个只读或仿真的 AI Agent for Robot Operations

  • RAG 检索设备和机器人手册;
  • 读取 ROS2 topic、TF、日志和状态;
  • 使用 Skills 固化排障步骤;
  • 必要时用 subagents 并行调查不同证据;
  • 输出诊断、候选计划、命令草案和影响范围;
  • 通过策略、仿真和人工审批后,才进入确定性执行层;
  • 全程保留 trace、事件日志、拒绝原因和失败样本。

这个项目能同时证明系统集成、状态建模、实时数据 UI、Agent 工具设计、权限、安全边界和可观测性,比展示“创建了十个 Agent 角色”更有面试价值。

持续观察渠道

热点不应只靠社交媒体时间线。更稳定的信息雷达是:

  • 官方产品与实现: OpenAI、Anthropic、Google、Moonshot、Microsoft、LangChain、LlamaIndex 的文档、release notes 和 GitHub;
  • 协议与生态: MCP specification/Registry、A2A releases、Agent Skills specification、OpenTelemetry GenAI、OWASP Agentic Security;
  • 公开评测: SWE-bench、Terminal-Bench、GAIA、BrowseComp、tau-bench、OSWorld、BFCL、MCP Atlas;
  • 论文发现: arXiv、OpenReview、ACL Anthology、Hugging Face Papers,但新预印本只作为待验证线索;
  • 实践者信号: Simon Willison、Latent Space、Matt Pocock、Superpowers、GitHub Trending、Hacker News,用于发现项目,不用于证明能力。

当前最值得持续验证的热点包括:K3 上是否出现可审计的 Agent Swarm 结果、Agent Skills 跨宿主兼容与供应链安全、A2A 1.0 与 MCP 的联合身份和 trace、原生 Harness 长程评测,以及强单 Agent 是否继续缩小同质多 Agent 的收益。

最终的判断标准始终相同:这个新东西是否改变了任务成功率、墙钟时间、成本、可恢复性、权限边界或验证质量。如果没有,就先把它留在观察清单,而不是加入生产架构。

主要参考资料

先说结论:基础没有消失,资深标准正在变化

Vibe Coding 降低了代码生成和 API 查询成本,但没有降低软件失败的代价。代码来得越快,审查边界、验证行为、定位故障和对结果负责越重要。

面试也不会整齐地从“八股”切换到 AI 协作。不同公司、岗位和面试官差异很大,标准化基础题仍是成本较低的筛选手段。对资深工程师,更常见的有效追问是:

  • 为什么这个机制会产生当前现象?
  • 结论在哪些条件下失效?
  • 出现性能、并发或安全问题时怎样定位?
  • 为什么选择这个方案,替代方案有什么代价?
  • 这是生产经验、个人实验,还是根据原理作出的推断?
  • AI 生成的代码看似能跑,怎样证明它可以进入系统?

因此复习策略不是“继续背完所有题”或“以后全靠 AI”,而是:

基础知识达到稳定过线水平,主要精力投入项目深挖、工程取舍、代码审查和验证证据。

对已有 11 年 Web/full-stack 经验、正在转向机器人系统软件和工业可视化的工程师,Vue、TypeScript 与 Node.js 是能力底座和差异化资产,不应成为占满全部学习时间的舒适区。

四层知识地图

能力层 掌握标准 Vue / TypeScript / Node.js 示例 复习策略
必须闭卷掌握 能用自己的语言解释机制,并写出最小代码 响应式与更新调度、控制流收窄与类型擦除、事件循环与错误传播 周期性回忆,五分钟讲清,接受两层追问
资深工程追问 能联系故障、性能、安全、可靠性和架构边界 更新风暴、异步竞态、运行时数据污染、事件循环阻塞、背压、优雅停机 用真实项目或可复现实验回答,说明取舍和指标
可以借助文档或 AI 知道从哪里查、如何做最小实验确认 冷门 API 参数、少用配置、精确守卫顺序、版本相关实现细节 不长期背诵,面试前按职位描述定向恢复
AI 代码审查能力 能指出风险、修改理由和验证证据 断言代替校验、资源泄漏、缺少超时、错误吞没、无界并发、错误权限假设 保留 AI 初稿、审查记录、修复和测试结果

哪些内容不值得长期投入

  • 仅对某个旧版本成立、又能快速查询的执行顺序。
  • 为展示技巧而构造的 TypeScript 递归类型谜题。
  • Vue 2 的冷门 API 和已经退出主流项目的配置细节。
  • 没有输入规模、环境和测量数据的性能结论。
  • 脱离业务不变量的“最佳实践”列表。

这不表示这些内容永远不会被问到,而是它们不应挤占建立新职业证据的时间。遇到目标公司明确使用旧技术栈时,再进行定向准备。

三条专题主线

Vue:从 API 使用推进到更新模型

闭卷掌握响应式依赖、更新调度、computed/watchkey 和组件状态所有权。资深追问集中在高频更新、异步竞态、组件边界、大列表和 Web3D 对象如何避免无意义深代理。

目标不是背 Vue 源码函数名,而是能把一次状态写入追踪到 effect、scheduler、组件渲染和 DOM patch,并知道如何用 Vue Devtools 与浏览器 Performance 验证。

TypeScript:从“有类型”推进到可信边界

闭卷掌握结构化类型、unknown、判别联合、泛型关系、类型收窄和类型擦除。资深追问集中在公共 API 类型、方差、严格配置、声明文件、运行时输入和类型测试。

看到 AI 生成的 req.body as Commandresponse.json() as User 时,应立即追问:数据由谁验证、验证了哪些业务约束、失败如何处理、类型断言的证据是什么。

Node.js:从“异步非阻塞”推进到服务可靠性

闭卷掌握 V8/libuv/OS 的职责、事件循环稳定关系、I/O 与 CPU 任务边界、Stream 背压和错误传播。资深追问集中在尾延迟、事件循环阻塞、内存、超时取消、幂等、优雅停机和可观测性。

回答“Node.js 是单线程”时必须说明指的是 JavaScript 执行模型,而不是整个运行时只有一个线程。回答“流省内存”时必须说明缓冲、背压、并发和测量条件。

仍需单独准备的通用基础

这三条主线不能代替 JavaScript 语言、浏览器、网络、数据结构、数据库和系统设计。至少要能解释:

  • 作用域、闭包、原型、Promise、微任务和错误传播。
  • 浏览器渲染、布局绘制、事件、缓存、同源策略和常见安全边界。
  • HTTP 方法语义、状态码、缓存、Cookie、认证与授权。
  • 常见数据结构复杂度、并发限制、数据库索引和事务边界。

本轮文章不重复扩写这些主题,避免把索引变成无法复习的百科全书。

资深回答的六步结构

面对原理题或场景题,可以按六步组织,而不是一上来倾倒术语:

  1. 结论:先直接回答问题,限定讨论条件。
  2. 机制:说明关键数据流、控制流或类型关系。
  3. 场景:把机制放回输入规模、并发、生命周期和业务目标。
  4. 风险:指出错误、安全、性能、一致性和维护风险。
  5. 替代方案:比较至少一个可行替代,并说明采用成本。
  6. 验证与证据:给指标、profile、日志、测试、故障复盘或最小实验;没有生产经历时明确说是实验或推理。

“我会使用虚拟列表”只是方案名称;“先证明 DOM 数量和 layout/paint 是主要瓶颈,再决定虚拟列表窗口、滚动定位和可访问性策略”才体现资深判断。

跨栈场景:机器人与工业设备状态平台

问题:5,000 台设备高频更新,页面越来越卡,如何定位?

1. 先定义现象和目标

确认是消息延迟、交互卡顿、图表掉帧还是内存上涨;记录设备数、每台更新频率、单条消息大小、允许的 UI 新鲜度和报警延迟。没有这些条件,“优化 Vue”没有明确目标。

2. 把链路拆开测量

1
2
3
4
5
6
7
设备 / 模拟器
-> Node.js 网关接收与解析
-> 校验、聚合和消息分发
-> WebSocket 客户端接收
-> TypeScript 领域模型
-> Vue 响应式状态与组件更新
-> DOM / Canvas / WebGL 绘制
  • 网关观察吞吐量、事件循环延迟、CPU、内存、队列深度和下游耗时。
  • 浏览器观察消息处理耗时、Long Task、Vue 组件更新、DOM 数量、layout/paint 和帧率。
  • 给消息加时间戳与 correlation ID,区分服务端积压、网络延迟和前端处理延迟。

3. 分离采集频率与展示频率

设备数据可以高频到达,但人眼和 DOM 不需要逐条刷新。把数据写入有界缓冲,按 100 至 250 ms 合并为 UI 快照。位姿展示可按设备保留最新值;报警、审计和控制结果不能用同样策略静默覆盖。

4. 守住类型和业务边界

WebSocket 数据先视为 unknown,验证消息类型、设备标识、时间戳、数值范围和版本,再转换为判别联合。TypeScript 接口不能代替这些检查。

5. 根据证据选择优化

  • Vue 更新过多:批量发布快照、稳定 props、减少无意义深响应。
  • DOM 数量过多:虚拟滚动、分组展开或只展示视口数据。
  • Canvas/WebGL 绘制过重:控制渲染频率、复用对象、减少材质和 draw call。
  • 主线程解析或计算过重:评估 Worker,并计入数据复制与调度成本。
  • Node.js 消费跟不上:使用背压、有界并发、批处理、过载拒绝或分区扩展。

6. 给出验收指标

例如:在 5,000 台设备、每台每秒 10 条模拟遥测下,网关队列不持续增长;报警端到端 p99 小于约定阈值;页面交互无超过约定时长的 Long Task;稳态内存不随运行时间持续增长。具体阈值必须由业务和测试环境确定,不能把示例数字写成行业标准。

AI 生成代码审查练习

下面是一段表面上能工作的 AI 生成代码:

1
2
3
4
5
app.post('/devices/:id/command', async (req, res) => {
const command = req.body as RobotCommand
await sendCommand(req.params.id, command)
res.json({ ok: true })
})

对于普通信息系统,这段代码已经缺少关键边界;对于机器人命令接口,直接上线更不可接受。

至少应发现的问题

  1. as RobotCommand 只是类型断言,没有验证请求体结构、数值范围和命令版本。
  2. 没有认证,也没有验证用户是否有权操作目标设备和当前空间。
  3. 没有命令 allowlist、安全门控、设备状态检查和速度/工作空间限制。
  4. 没有超时、取消和下游无响应时的行为定义。
  5. 不清楚命令是否幂等;客户端重试可能造成第二次物理动作。
  6. 没有 command ID、审计记录、操作者、批准信息和结果状态。
  7. async 失败如何进入 Express 错误边界不明确,可能产生未处理 rejection 或错误响应。
  8. 没有同一设备的并发、顺序、状态冲突和急停优先级策略。
  9. HTTP 返回成功是否表示“已接收”“已规划”“已下发”还是“已执行完成”不明确。
  10. 没有指标、trace 和安全事件日志,故障后无法还原链路。

修正方向

安全的接口不是增加几个 try/catch。它至少需要如下分层:

1
2
3
4
5
6
7
8
HTTP 边界
-> 认证与资源授权
-> 运行时 schema + 领域约束校验
-> command ID / 幂等与设备级并发策略
-> 工作流和人工批准
-> 确定性安全控制与硬件互锁
-> 下发、反馈、超时与状态查询
-> 审计、指标、日志和告警

LLM 可以生成 handler、schema 初稿和测试样例,但它不能从不存在的需求中推导出可信权限、设备安全状态和物理约束。工程师必须补齐系统保证,并通过测试、仿真、故障注入和现场安全流程验证。

复习时应保留 AI 初稿、审查清单、修正后的代码与测试结果。这个过程比只展示“我用 AI 很快写完接口”更能证明新时代的工程能力。

面向当前职业转型的复习投入

以下比例针对“资深 Web/full-stack 工程师转向机器人系统软件、工业可视化和机器视觉”的当前阶段,不是所有读者的通用公式。

非求职冲刺期

投入 方向 交付物
45% 机器人数字孪生、调试台、视觉引导或设备状态项目 可运行 Demo、架构图、事件日志、失败案例和演示视频
25% C++、Python、Linux、坐标系、运动学和工业通信 SDK 调用、坐标变换实验、协议模拟器、测试记录
15% JavaScript、Vue、TypeScript、Node.js 机制恢复 机制卡、最小示例、两层追问答案
10% AI 辅助编码、代码审查、测试和验证 AI 初稿、拒绝理由、修复 diff、自动化测试
5% 项目复盘、简历叙事和口头表达 STAR/架构复盘、三分钟与二十分钟两个版本

这个配置的理由是:Web 基础已经是优势,继续投入会提高熟练度,却不能单独证明机器人系统能力。当前更稀缺的是把 Web3D、实时数据、设备状态、视觉和安全约束整合成可检查的作品。

投递前六至八周

把通用编程基础、编码练习和模拟面试提高到总时间的 30% 至 35%,依据目标职位描述补齐算法、网络、数据库和目标语言;同时继续维护项目 Demo 和证据,不要在面试前把作品开发完全停掉。

目标职位不同,权重也应调整:

  • 工业数字孪生 / 机器人可视化:Vue/Three.js、实时数据、性能和坐标系权重更高。
  • 机器人应用软件 / 系统集成:Node/Python/C++、设备协议、状态机、异常恢复权重更高。
  • 机器视觉应用:OpenCV、标定、PnP、手眼标定、部署与光学条件权重更高。
  • 纯 VLA、强化学习或控制算法岗位:当前仍是高跨度方向,不能用 Web 八股或 Agent 使用经验替代数学、训练和机器人实验能力。

分级自测题

A. 基础事实:能否直接回答

  • Vue 的 computedwatch 分别解决什么问题?
  • key 为什么首先是身份问题?
  • TypeScript 的 unknownany 有什么差别?
  • 类型断言为什么不能校验 JSON?
  • Node.js 所谓“单线程”的边界是什么?
  • writable.write() 返回 false 表示什么?

B. 机制解释:能否继续追问两层

  • 一次 Vue 响应式写入怎样到达 DOM 更新?
  • 为什么从 reactive 直接解构可能丢失响应性?
  • 判别联合怎样与 never 形成穷尽性检查?
  • Promise 微任务、process.nextTick() 和事件循环是什么关系?
  • pipeline() 比连续 pipe() 多承担了什么职责?

C. 工程决策:能否比较方案

  • 高频设备数据应该每条更新 UI、定时批量发布还是只保留最新值?不同消息是否应使用相同策略?
  • 什么情况下选择 Worker Threads,什么情况下选择独立进程或服务?
  • 运行时校验使用手写 guard 还是 schema 库,怎样决定?
  • Vue 页面缓存解决了什么,数据缓存又由谁负责?

D. 故障诊断:能否提供证据

  • 页面运行两小时后内存持续增长,先看哪些指标和 profile?
  • Node.js 平均延迟正常但 p99 很高,怎样分层定位?
  • 路由参数切换后偶尔显示上一台设备,原因和修复是什么?
  • AI 生成的重试逻辑导致设备动作重复,系统缺了哪些保证?

E. 项目证据:能否诚实说明边界

  • 哪个项目证明你处理过实时数据、背压或高频渲染?
  • 哪个故障是你亲自定位的,最初假设与最终原因是否一致?
  • 哪些结论只有个人实验,还没有生产验证?
  • 如果重新设计当前项目,会保留和改变什么?

可执行复习清单

机制卡

  • [ ] 每张卡只覆盖一个机制,包含结论、运行过程、失败边界和最小实验。
  • [ ] 能闭卷讲五分钟,并回答至少两层“为什么”和“什么时候失效”。
  • [ ] 面试价值:通过基础筛选,避免资深候选人在核心原理上失速。

项目深挖包

  • [ ] 准备架构图、关键接口、状态流、技术取舍、性能数据、测试和一次失败复盘。
  • [ ] 能围绕同一项目接受二十分钟追问,而不是只复述产品功能。
  • [ ] 面试价值:证明系统集成、正确性、可靠性、可观测性和交付能力。

AI 代码审查记录

  • [ ] 保留原始提示和生成代码,不把修正后的结果伪装成人工一次写成。
  • [ ] 标出接受、拒绝和需要实验确认的部分,并写明证据。
  • [ ] 用测试、静态检查、基准或故障注入验证修正结果。
  • [ ] 面试价值:证明会使用 AI,同时保留工程判断和结果责任。

职业转型证据

  • [ ] 至少完成一个机器人调试台、数字孪生或视觉引导工作流的可运行作品。
  • [ ] README 说明数据流、状态、坐标系、安全边界、故障处理和测试策略。
  • [ ] 明确区分已有 Web 工程证据、正在形成的机器人软件能力和尚未证明的算法能力。
  • [ ] 面试价值:把“11 年 Web 工程师想转机器人”变成“能够交付面向机器人的软件系统,并持续补强机器人深度”。

更新时间:2026-07

核心观点:Codebase Memory MCP 的本质不是 AI Coding Agent,而是 AI 的 Context Layer,用于帮助 AI 快速理解大型代码库。

一、项目简介

Codebase Memory MCP 是一个 MCP(Model Context Protocol)Server。它能够扫描整个代码仓库,构建代码知识图谱(Knowledge Graph),供 Claude Code、Codex、Cursor 等 AI Agent 查询。

它的目标不是代替 IDE,也不是直接生成代码,而是解决 AI Coding 中最重要的问题之一:

Context Retrieval(上下文获取)

对于大型项目,AI 往往需要花费大量时间:

  • grep 搜索
  • 查找文件
  • 阅读大量无关源码
  • 建立调用关系

Codebase Memory MCP 将这些工作提前完成,并保存成结构化知识图谱,使 AI 能够通过查询而不是全文扫描获取上下文。

二、它解决的问题

传统 AI Coding 工作方式大致是:

1
2
3
4
5
6
7
8
9
10
11
Agent

grep

read file

grep

read file

...

大型仓库中,大量 Token 会消耗在“寻找代码”上。

使用 Codebase Memory MCP 后,工作方式变成:

1
2
3
4
5
6
7
Agent

MCP Tool Call

Knowledge Graph

返回结构化关系

例如,Agent 想知道:

1
谁调用了 ProcessOrder()?

无需 grep 整个仓库,可以直接返回类似关系:

1
2
3
4
5
6
7
CheckoutController

OrderApplication

OrderService

ProcessOrder()

三、核心思想

1. 它不是文档

很多人容易把它理解成:

1
2
3
4
5
Repository

生成 Markdown

AI 阅读 Markdown

这个理解不准确。

更接近真实的流程是:

1
2
3
4
5
6
7
AI Agent

MCP Tool

Knowledge Graph(SQLite)

返回 JSON

例如返回:

1
2
3
4
5
6
7
{
"symbol": "OrderService",
"called_by": [
"CheckoutController",
"RetryWorker"
]
}

AI 获取的是结构化数据,而不是一大段索引文本。

2. 它更像数据库

Codebase Memory 保存的对象可能包括:

  • Class
  • Function
  • Method
  • Package
  • Interface
  • Module
  • Route
  • Resource

保存的关系可能包括:

  • CALLS
  • IMPORTS
  • IMPLEMENTS
  • HTTP_CALLS
  • DATA_FLOW
  • TESTS
  • CONFIGURES

因此它更像:

1
2
3
4
5
Neo4j
+
SQLite
+
MCP Server

而不是:

1
2
3
Markdown
+
RAG

四、整体工作流程

第一步:扫描代码仓库

启动 Codebase Memory MCP 后,它会解析整个代码仓库:

1
2
3
4
5
Repository

Tree-sitter

AST

第二步:构建知识图谱

从 AST 和工程结构中抽取:

  • Class
  • Method
  • Symbol
  • Import
  • Call Graph
  • Dependency

最终形成:

1
Knowledge Graph(SQLite)

例如:

1
2
3
4
5
6
7
OrderController

OrderService

Redis

MySQL

第三步:启动 MCP Server

MCP Server 向 AI 提供一系列 Tool,例如:

  • find_symbol
  • search_graph
  • trace_call_path
  • architecture_summary
  • impact_analysis
  • query_graph

Agent 不需要知道数据库位置,只需要调用 Tool。

第四步:AI Coding

例如用户提出需求:

1
增加订单取消功能

Agent 可能执行:

1
2
3
4
5
6
7
8
9
10
11
architecture_summary()

find_symbol()

trace_call_path()

impact_analysis()

read_file()

edit_file()

注意:Graph 并不会替代阅读源码。

它只是帮助 Agent 找到应该阅读哪些源码

第五步:修改后更新图谱

修改代码后,通常有两种方式更新图谱。

方式一:全量重新扫描

1
2
3
Repository

重新 Index

优点是简单,缺点是大型项目速度较慢。

方式二:增量更新(推荐)

监听文件变化:

1
2
3
4
5
OrderService.java

重新解析 AST

更新 Graph

这样无需重新扫描整个仓库。

五、完整工作流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Git Repository


Codebase Memory MCP


Tree-sitter AST


Knowledge Graph(SQLite)


MCP Server

├──────────────┐
▼ ▼
Claude Code Codex CLI
│ │
└────Tool Calls┘

查询调用关系
查询依赖
查询影响范围


精准定位源码


修改代码


文件监听 / 增量更新 Graph

六、与传统 RAG 的区别

传统 RAG 流程:

1
2
3
4
5
6
7
8
9
Question

Embedding

Vector Search

Chunk

LLM

返回结果通常是文本片段。

Knowledge Graph 流程:

1
2
3
4
5
6
7
8
9
Question

Graph Query

Node

Relation

LLM

返回结果通常是结构化关系,例如:

1
2
3
4
5
Controller

Service

Repository

它返回的不是“几段可能相关的文本”,而是“实体和实体之间的关系”。

七、与 Sourcegraph 的区别

Sourcegraph Codebase Memory MCP
面向开发者 面向 AI Agent
Code Search Graph Query
Web UI MCP Tool
人浏览代码 AI 查询上下文
代码导航 AI Context Provider

Sourcegraph 更像:

Google Search

Codebase Memory 更像:

Neo4j + MCP

八、优势

1. 快速理解大型仓库

减少:

  • grep
  • find
  • read

提升 AI 获取上下文的效率。

2. Call Graph 查询

例如:

1
谁调用 ProcessOrder()?

无需扫描整个仓库。

3. Impact Analysis

例如:

1
修改 User 实体,会影响哪些模块?

Graph 可以帮助分析:

  • Controller
  • Service
  • Repository
  • Test
  • API

4. Architecture Summary

快速生成:

  • Layer
  • Module
  • Entry Point
  • Hotspot

帮助 Agent 理解整体架构。

5. 支持大型仓库

相比反复读取源码,Graph 查询通常:

  • 更快
  • Token 更少
  • 上下文更稳定

九、局限性

1. 不理解设计原因

Graph 能回答:

  • What
  • How

但不能直接回答:

  • Why

例如:

1
为什么这里不用 RabbitMQ?

这类问题仍然需要:

  • ADR
  • Design Doc
  • PR
  • Issue
  • Wiki

2. 动态语言分析有限

对于以下场景,静态图谱无法完全覆盖:

  • Reflection
  • 动态导入
  • Plugin
  • Runtime Dispatch

3. 不能替代 IDE

IDE 仍负责:

  • Rename
  • Refactor
  • Diagnostics
  • Go To Definition

Knowledge Graph 负责帮助 AI 理解项目。两者定位不同。

十、个人评价

技术价值

评分:★★★★★(9.5/10)

非常适合:

  • 大型代码仓库
  • AI Coding
  • MCP Agent
  • 企业级项目

成熟度

评分:★★★★☆(约 7-8/10)

项目发展很快,但仍需注意:

  • API 可能持续变化
  • 生态仍在快速演进
  • 不同语言和框架的图谱质量会有差异

是否值得学习

值得。

真正值得学习的不是某个具体 API,而是背后的工程思想:

Context Engineering(上下文工程)

未来 AI Coding 的竞争力,不再只是更强的大模型,而是更完整的上下文系统:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Git

Code Graph

ADR

Design Docs

Issue

Runtime Trace

Knowledge Base

AI Agent

LLM 只是最上层。真正决定 AI Agent 能力上限的是:

Context Layer(上下文层)

十一、我的总结

Codebase Memory MCP 并不是一个“更会写代码”的 AI Agent,而是 AI Agent 的“导航系统”。

它通过预先构建代码知识图谱,让 Agent 从“反复扫描源码”转变为“查询结构化上下文”,从而显著降低 Token 消耗,提高定位效率,并增强对大型代码仓库的理解能力。

对于 AI Native 开发而言,它代表了一种重要方向:

AI 的能力不仅取决于模型本身,更取决于提供给模型的上下文质量。

随着 AI 开发工具的发展,代码知识图谱很可能会与 ADR、设计文档、Issue、运行时监控、测试结果等信息融合,形成完整的 Context Layer,成为未来 AI Native 软件工程的重要基础设施。

后续可继续研究的问题

这篇笔记后续可以继续补充:

  • Codebase Memory MCP 与 LSP 的边界
  • Tree-sitter 对不同语言的解析能力差异
  • 静态图谱与运行时 Trace 如何融合
  • 如何把 ADR、Issue、PR、测试结果纳入 Context Layer
  • 如何评估一个 Code Graph 对 AI Coding 的真实帮助

涉及项目最新状态、API、安装方式或版本兼容性时,需要重新查证。

背景

在机器人数字孪生、运动规划可视化、碰撞检测和工业仿真中,经常会遇到两类模型:

  • 机器人结构模型:描述机器人有哪些连杆、关节、坐标系、关节轴、关节限位。
  • CAD 几何模型:描述零件真实形状、曲面、实体、装配关系、STEP/IGES 等工程格式。

URDF 和 OCCT 分别对应这两类问题。

一句话理解:

URDF 负责“机器人是什么结构、怎么运动”;OCCT 负责“几何实体是什么形状、如何加工和转换”。

在机器人软件里,URDF 更偏机器人语义,OCCT 更偏 CAD 几何内核。

一、URDF 是什么

URDF 全称是 Unified Robot Description Format,是 ROS 生态中常用的机器人描述格式,本质是一个 XML 文件。

它主要描述:

  • 机器人由哪些 link 组成。
  • link 之间通过哪些 joint 连接。
  • 每个关节的类型、轴向、限位和初始变换。
  • 每个 link 的视觉模型、碰撞模型和惯性参数。

URDF 不是三维建模软件,也不是运动控制算法。它更像机器人系统中的“结构配置文件”。

二、URDF 的核心元素

1. robot

robot 是根节点,表示一个机器人模型。

1
2
3
<robot name="six_axis_robot">
...
</robot>

link 表示机器人上的刚体部件,例如:

  • base_link
  • shoulder_link
  • upper_arm_link
  • wrist_link
  • tool0

一个 link 可以包含三类信息:

  • visual:给人看的外观模型。
  • collision:给碰撞检测用的简化模型。
  • inertial:给动力学仿真用的质量、质心和惯性矩阵。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
<link name="base_link">
<visual>
<geometry>
<mesh filename="package://robot_description/meshes/base.stl"/>
</geometry>
</visual>

<collision>
<geometry>
<mesh filename="package://robot_description/meshes/base_collision.stl"/>
</geometry>
</collision>
</link>

3. joint

joint 表示两个 link 之间的连接关系。

常见关节类型:

  • fixed:固定连接。
  • revolute:有限角度旋转关节。
  • continuous:无限旋转关节。
  • prismatic:直线滑动关节。
  • floating:六自由度浮动关节。
  • planar:平面运动关节。

六轴工业机器人通常主要由多个 revolute 关节组成。

示例:

1
2
3
4
5
6
7
<joint name="joint_1" type="revolute">
<parent link="base_link"/>
<child link="link_1"/>
<origin xyz="0 0 0.35" rpy="0 0 0"/>
<axis xyz="0 0 1"/>
<limit lower="-3.14" upper="3.14" effort="150" velocity="2.5"/>
</joint>

关键字段:

  • parent:父 link。
  • child:子 link。
  • origin:child frame 相对 parent frame 的初始变换。
  • axis:关节运动轴,在 joint frame 中表达。
  • limit:关节上下限、最大力矩、最大速度。

三、URDF 中的坐标系

URDF 的关键不是“模型长什么样”,而是“坐标系如何连接”。

一个典型链路可以理解为:

1
2
3
4
5
6
7
8
9
world

base_link
↓ joint_1
link_1
↓ joint_2
link_2
↓ ...
tool0

每个 joint 的 origin 都是一段刚体变换。机器人正运动学就是把这些变换按链路顺序连乘:

1
2
3
4
5
T_base_tool =
T_base_link1(q1) ×
T_link1_link2(q2) ×
...
T_linkN_tool(qN)

这也是 URDF 能服务于 TF、FK、可视化和碰撞检测的原因。

四、URDF 的视觉模型与碰撞模型

URDF 中的几何一般分为两套:

visual

用于显示,模型可以比较精细,例如高面数 STL、DAE、OBJ。

作用:

  • RViz 显示
  • Web3D 显示
  • 数字孪生展示
  • Demo 演示

collision

用于碰撞检测,模型通常要简化。

原因:

  • 高精度 CAD 网格计算慢。
  • 碰撞检测更关注是否接触,不一定需要完整外观。
  • 工业场景通常需要稳定、快速、可解释。

常见做法:

  • 用 box、cylinder、sphere 代替复杂零件。
  • 用低面数 mesh。
  • 把复杂 link 拆成多个简单 collision primitive。

五、URDF 的局限

URDF 很适合描述树状机器人结构,但也有明显局限:

  • 不擅长表达闭链结构。
  • 不适合直接表达复杂装配约束。
  • 不负责路径规划、控制算法和任务逻辑。
  • XML 对大型模型可读性一般,通常会结合 xacro 模板生成。
  • 对工业机器人厂家私有参数、控制器语义、工艺坐标系表达有限。

因此 URDF 更适合作为机器人软件的“结构中间层”,而不是完整的机器人产品数据模型。

六、OCCT 是什么

OCCT 全称是 Open CASCADE Technology,是一个开源 CAD/CAM/CAE 几何建模内核。

它擅长处理:

  • B-Rep 实体建模
  • 曲线、曲面、拓扑结构
  • STEP、IGES 等 CAD 格式导入导出
  • 布尔运算
  • 倒角、圆角、偏移
  • 网格剖分
  • 几何测量
  • 装配和形状遍历

如果说 URDF 更像机器人结构描述文件,那么 OCCT 更像 CAD 软件背后的几何引擎。

七、OCCT 的核心概念

1. Geometry 与 Topology

OCCT 中需要区分两个概念:

  • Geometry:数学几何,例如点、线、圆、曲线、平面、曲面。
  • Topology:拓扑结构,例如点、边、线框、面、壳、实体。

简单理解:

1
2
Geometry:形状背后的数学定义
Topology:这些几何对象如何连接成一个实体

例如一个圆柱体:

  • Geometry 包含圆柱曲面、上下两个平面。
  • Topology 包含 face、edge、wire、shell、solid。

2. Shape 层级

OCCT 常见拓扑层级:

1
2
3
4
5
6
7
8
9
10
11
12
13
Vertex

Edge

Wire

Face

Shell

Solid

Compound

含义:

  • Vertex:点。
  • Edge:边。
  • Wire:边组成的闭合或非闭合线框。
  • Face:面。
  • Shell:多个面组成的壳。
  • Solid:封闭体。
  • Compound:多个 Shape 的组合。

3. B-Rep

B-Rep 是 Boundary Representation,边界表示法。

它不是用三角面片直接表示实体,而是用边界曲面和拓扑关系表示实体。

对比:

1
2
3
4
5
Mesh:
三角形 + 顶点

B-Rep:
曲面 + 边界 + 拓扑关系

这也是 CAD 模型比普通游戏模型更适合工程计算的原因。

八、OCCT 常见能力

1. 读取 CAD 文件

OCCT 可以读取 STEP、IGES 等工程 CAD 格式。

典型用途:

  • 导入机械臂零件 STEP 文件。
  • 导入夹具、工装、工件模型。
  • 提取装配体中的零件层级。

2. 几何测量

可用于计算:

  • 包围盒
  • 体积
  • 面积
  • 质心
  • 距离
  • 干涉关系

这些能力在机器人仿真和工装布局中很有价值。

3. 布尔运算

常见布尔操作:

  • Fuse:并集。
  • Cut:差集。
  • Common:交集。

在工装设计、碰撞空间构造、夹具简化时会用到。

4. 网格剖分

机器人可视化和 Web3D 通常不能直接渲染 B-Rep,需要把 CAD 模型转成 mesh。

流程大致是:

1
2
3
4
5
6
7
8
9
STEP / IGES

OCCT 读取为 B-Rep Shape

Mesh triangulation

STL / OBJ / glTF

Three.js / RViz / Web Viewer

九、URDF 与 OCCT 的关系

URDF 和 OCCT 不是同一层东西。

维度 URDF OCCT
核心定位 机器人结构描述 CAD 几何建模内核
主要数据 link、joint、axis、limit、origin shape、face、edge、solid、surface
文件形态 XML C++ API / CAD 文件处理库
常见输入 STL、DAE、OBJ、xacro STEP、IGES、BREP
常见输出 机器人模型树 B-Rep、mesh、测量结果
适合问题 FK、TF、可视化、碰撞配置 CAD 导入、几何分析、网格转换

更合理的关系是:

1
2
3
4
5
6
7
8
9
10
11
CAD / STEP

OCCT

几何清理、简化、剖分、导出 mesh

STL / DAE / glTF

URDF visual / collision

机器人仿真 / 可视化 / 碰撞检测

也就是说:

OCCT 可以帮助准备和处理 URDF 中引用的几何资源,但 URDF 负责表达机器人运动结构。

十、在机器人数字孪生中的使用方式

一个机器人数字孪生调试台可能包含这些模块:

1
2
3
4
5
6
7
8
9
10
11
URDF

解析 link / joint / limit / axis

构建机器人层级树

加载 mesh

FK 更新 link transform

Three.js 显示机器人姿态

如果引入 OCCT,则可以扩展 CAD 处理链路:

1
2
3
4
5
6
7
8
9
10
11
STEP 工装模型

OCCT 读取

提取装配层级和包围盒

生成可视化 mesh

导入 Three.js 场景

用于布局、干涉检查和碰撞区域显示

对 Web/可视化工程师来说,关键不是一开始就实现完整 CAD 内核,而是理解数据层次:

  • URDF 提供机器人运动链。
  • Mesh 提供可显示外观。
  • Collision geometry 提供碰撞近似。
  • OCCT 提供 CAD 到 mesh/测量/简化的工程能力。

十一、学习重点

URDF 学习重点

先掌握:

  • link / joint / origin / axis / limit
  • visual 和 collision 的区别
  • base、flange、tool0、TCP 的关系
  • URDF 到 TF tree 的转换
  • URDF 与 FK 的关系
  • xacro 的基本模板化能力

再深入:

  • inertial 参数
  • Gazebo / ros2_control 扩展
  • Mimic joint
  • 多机器人命名空间
  • 碰撞模型简化策略

OCCT 学习重点

先掌握:

  • STEP / IGES / STL / glTF 的区别
  • B-Rep 与 Mesh 的区别
  • Shape、Face、Edge、Solid 的层级
  • CAD 模型导入和遍历
  • 包围盒、体积、距离等基础测量
  • CAD 到 mesh 的转换流程

再深入:

  • 布尔运算
  • 曲面修复
  • 装配结构解析
  • 网格精度控制
  • 与碰撞检测库的衔接

十二、容易混淆的点

1. URDF 不是 CAD 格式

URDF 可以引用 mesh 文件,但它本身不是 CAD 文件。

它关心的是机器人结构、关节、坐标系和运动关系。

2. STL 不是机器人模型

STL 只是一堆三角面片,不知道哪个部分是 link,也不知道关节轴和关节限位。

要让 STL 成为机器人模型的一部分,需要 URDF 提供结构语义。

3. CAD 高精度不等于仿真好用

CAD 模型通常过于复杂,直接用于实时可视化和碰撞检测会很慢。

工程上经常需要:

  • visual 模型适度降面。
  • collision 模型大幅简化。
  • 保留关键外形,去掉螺丝孔、倒角、小特征。

4. 坐标系比模型外观更重要

机器人模型看起来对,不代表运动学一定对。

更关键的是:

  • joint origin 是否正确。
  • joint axis 是否正确。
  • mesh 坐标是否和 link frame 对齐。
  • 单位是否一致。
  • tool0 / flange / TCP 是否区分清楚。

十三、最小实践建议

练习一:读懂一个六轴机械臂 URDF

交付物:

  • 画出 link-joint 树。
  • 标出 6 个 joint 的 axis。
  • 列出每个 joint 的 lower / upper / velocity。
  • 解释 base_link、flange、tool0 的关系。

验收标准:

  • 能用自己的话解释每个 joint 的父子 link。
  • 能指出 TCP 位姿由哪些 transform 连乘得到。
  • 能说明 visual mesh 和 collision mesh 是否一致。

面试价值:

证明自己不是只会看 3D 模型,而是能理解机器人结构数据和坐标系。

练习二:CAD 到机器人可视化资源转换

交付物:

  • 找一个 STEP 零件。
  • 用 OCCT 或基于 OCCT 的工具读取。
  • 导出 STL/glTF。
  • 放入 Three.js 或 URDF visual 中显示。

验收标准:

  • 模型单位正确。
  • 模型朝向正确。
  • 包围盒尺寸符合预期。
  • 文件体积和面数适合实时显示。

面试价值:

证明自己理解工业 CAD 资源如何进入机器人软件和 Web3D 可视化链路。

练习三:构建简化碰撞模型

交付物:

  • 为一个复杂 link 建立简化 collision geometry。
  • 对比 visual mesh 和 collision mesh。
  • 记录简化前后的面数、包围盒和碰撞检测性能差异。

验收标准:

  • collision 模型不会明显漏掉关键外形。
  • 运行速度比高精度 mesh 更稳定。
  • 能解释为什么不能直接用完整 CAD 做实时碰撞。

面试价值:

证明自己具备工程取舍意识,而不是只追求模型精细。

十四、个人定位价值

对从 Web/可视化转向机器人软件来说,URDF 和 OCCT 很值得学习。

原因是它们正好连接了几个关键能力:

  • 机器人结构建模
  • 坐标系和运动学
  • CAD 工程数据
  • Web3D 可视化
  • 数字孪生
  • 碰撞检测和仿真

更现实的定位不是一开始就做底层控制算法,而是先成为能把机器人模型、CAD 资产、运动链路和可视化调试工具打通的人。

这条路线与工业机器人数字孪生、运动规划可视化、机器视觉工位仿真和机器人系统集成都高度相关。

十五、总结

URDF 和 OCCT 解决的是机器人软件中的两个不同层次的问题。

URDF 解决:

  • 机器人由哪些 link 和 joint 组成。
  • 关节如何运动。
  • 坐标系如何连接。
  • 可视化模型和碰撞模型如何挂到机器人结构上。

OCCT 解决:

  • CAD 几何如何读取。
  • B-Rep 实体如何表达。
  • STEP/IGES 如何转换成可渲染 mesh。
  • 几何如何测量、简化和处理。

它们组合起来,可以形成一条很实用的工程链路:

1
2
3
4
5
6
7
8
9
10
11
工业 CAD

OCCT 几何处理

Mesh 资源

URDF 机器人结构

FK / TF / 碰撞检测

Web3D / RViz / 数字孪生

这也是机器人应用软件和工业数字孪生方向中非常值得补强的基础知识。

背景

很多项目里的 Dockerfile 并不是为了把所有系统能力一次性打包到生产现场,而是先服务于更常规的 Web 工程目标:

  • 把前端项目打包成静态资源镜像
  • 把 API service 打包成服务镜像
  • 在 CI 中运行 lint、typecheck、unit test、integration test
  • 固定 Node、pnpm、Python、Rust 或其他构建环境版本
  • 让团队在本地和 CI 中使用一致的构建命令
  • 把可交付部分推送到 registry,供测试环境或生产环境拉取

这篇笔记整理一套常规 Web 应用适用的 Docker 发布流程。它主要面向前端、BFF、API service、后台任务 worker 这类服务,不讨论工业硬件 SDK、机器人控制、现场设备网络等更复杂的部署问题。

先给结论

常规 Web 应用的 Docker 发布目标不是“把开发机完整复制进镜像”,而是生成一个可重复、可追溯、可回滚的运行制品。

一条比较健康的 Web Docker 发布链路应该包含:

  1. .dockerignore 控制构建上下文。
  2. 多阶段 Dockerfile 区分依赖安装、构建、测试和运行镜像。
  3. 前端静态资源使用 Nginx、Caddy 或对象存储/CDN 发布。
  4. API service 使用精简 runtime image,只放运行所需文件。
  5. CI 使用相同 Dockerfile 或相同基础镜像运行 lint/test/build。
  6. 镜像 tag 使用版本号、git sha、环境和构建 profile,不依赖 latest
  7. 生产配置通过环境变量、secret、config mount 或平台配置注入,不写死在镜像里。
  8. 发布后有 healthcheck、日志、metrics 和回滚方案。

如果项目中的 Dockerfile 目前只是为了打包前端、API service 层,或者为了在 CI 中跑 lint/test,那么它首先应被看作 Web 工程的构建与验证工具。

Docker在Web项目中的常见用途

1. 本地开发环境

用 compose 启动数据库、Redis、消息队列、对象存储模拟器等依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data

redis:
image: redis:7
ports:
- "6379:6379"

volumes:
postgres-data:

这类 compose 文件主要用于开发和测试,不一定等同于生产部署文件。

2. CI中的lint和自动化测试

Docker 可以固定 CI 环境,避免“我本机能跑、CI 跑不了”:

1
2
docker build --target test -t web-api:test .
docker run --rm web-api:test

如果 Dockerfile 里定义了 linttestbuild 等 stage,CI 就可以复用同一份环境描述。

3. 前端静态资源发布

典型流程是:

  1. 用 Node 镜像安装依赖并构建。
  2. dist/build/ 复制到 Nginx/Caddy 镜像。
  3. 运行时只保留静态文件和 Web server,不保留完整 node_modules。

这样最终镜像通常会比直接把整个项目塞进 Node 镜像小很多。

4. API service发布

后端服务镜像通常只包含:

  • 编译后的应用代码
  • 生产依赖
  • 运行时配置入口
  • 健康检查入口
  • 必要 CA 证书、时区或系统库

不要把 .git、测试报告、开发缓存、未使用 SDK、私钥、.env 文件放进生产镜像。

项目结构建议

一个常见 Web 项目可以按下面方式组织:

1
2
3
4
5
6
7
8
9
10
11
12
13
project/
apps/
web/
Dockerfile
api/
Dockerfile
deploy/
compose.dev.yml
compose.prod.yml
nginx.conf
.dockerignore
package.json
pnpm-lock.yaml

也可以只有一个根目录 Dockerfile,但要把 stage 命名清楚:

1
2
3
4
5
6
base
deps
lint
test
build
runtime

重点不是目录必须长这样,而是团队要能一眼看懂:

  • 哪个镜像用于 CI
  • 哪个镜像用于前端发布
  • 哪个镜像用于 API service 运行
  • 哪个 compose 文件只用于本地开发
  • 哪个 compose 文件用于测试环境或生产环境

第一步:准备.dockerignore

.dockerignore 会影响 Docker build 的上下文大小,也影响是否把敏感文件带进构建过程。

常见内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.git
.github
.vscode
.idea
node_modules
dist
build
coverage
.next
.nuxt
.turbo
.cache
*.log
.env
.env.*

注意:如果 CI build 需要某些 Dockerfile 或 compose 文件,不要盲目忽略。规则要结合项目结构调整。

第二步:编写前端生产Dockerfile

以 Vite/React/Vue/Angular 这类前端应用为例,可以使用多阶段构建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# apps/web/Dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile

FROM deps AS build
COPY . .
RUN pnpm run build

FROM nginx:1.27-alpine AS runtime
COPY --from=build /app/dist /usr/share/nginx/html
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

对应的 Nginx SPA 配置可以类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
server {
listen 80;
server_name _;

root /usr/share/nginx/html;
index index.html;

location / {
try_files $uri $uri/ /index.html;
}

location /healthz {
access_log off;
return 200 "ok\n";
}
}

构建:

1
docker build -f apps/web/Dockerfile -t registry.example.com/app/web:1.0.0-a1b2c3d .

验证:

1
2
docker run --rm -p 8080:80 registry.example.com/app/web:1.0.0-a1b2c3d
curl http://localhost:8080/healthz

第三步:编写API service生产Dockerfile

以 Node API service 为例,重点是区分构建依赖和运行依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# apps/api/Dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile

FROM deps AS build
COPY . .
RUN pnpm run build
RUN pnpm prune --prod

FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/main.js"]

如果是 Go、Rust、Java、Python,思路类似:

  • build stage 负责安装编译工具和依赖
  • runtime stage 只保留运行所需的二进制、jar、venv 或源码
  • 镜像启动命令明确,配置从外部注入
  • 健康检查接口由应用提供,例如 /healthz/readyz

构建:

1
docker build -f apps/api/Dockerfile -t registry.example.com/app/api:1.0.0-a1b2c3d .

验证:

1
2
3
4
5
docker run --rm -p 3000:3000 \
-e DATABASE_URL="postgres://app:app@host.docker.internal:5432/app" \
registry.example.com/app/api:1.0.0-a1b2c3d

curl http://localhost:3000/healthz

第四步:为lint和test设计独立stage

如果 Dockerfile 的主要用途是 CI 中跑 lint 或自动化测试,可以显式定义目标 stage:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile

FROM deps AS lint
COPY . .
CMD ["pnpm", "run", "lint"]

FROM deps AS test
COPY . .
CMD ["pnpm", "run", "test"]

FROM deps AS build
COPY . .
RUN pnpm run build

CI 中分别执行:

1
2
3
4
5
docker build --target lint -t app-lint .
docker run --rm app-lint

docker build --target test -t app-test .
docker run --rm app-test

这类镜像不一定要推送到生产 registry。它们的价值是固定验证环境,而不是作为线上 runtime。

第五步:用compose编排本地和测试环境

常规 Web 应用可以用 compose 串起前端、API 和数据库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
services:
web:
image: registry.example.com/app/web:1.0.0-a1b2c3d
ports:
- "8080:80"
depends_on:
- api

api:
image: registry.example.com/app/api:1.0.0-a1b2c3d
environment:
NODE_ENV: production
DATABASE_URL: postgres://app:app@postgres:5432/app
REDIS_URL: redis://redis:6379
ports:
- "3000:3000"
depends_on:
- postgres
- redis
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
interval: 10s
timeout: 3s
retries: 5

postgres:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
volumes:
- postgres-data:/var/lib/postgresql/data

redis:
image: redis:7

volumes:
postgres-data:

启动:

1
docker compose -f deploy/compose.prod.yml up -d

查看状态:

1
2
docker compose -f deploy/compose.prod.yml ps
docker compose -f deploy/compose.prod.yml logs -f api

注意健康检查命令要确保镜像中真的存在。例如 Alpine 镜像里可能没有 curl,但通常有 wget;如果都没有,就需要安装工具或让应用二进制提供 healthcheck 命令。

第六步:配置和secret不要打进镜像

镜像应该尽量环境无关。开发、测试、预发、生产的差异应该来自外部配置:

  • 环境变量
  • secret manager
  • Kubernetes Secret/ConfigMap
  • Docker secret
  • 受控配置文件挂载
  • 发布平台参数

不要这样做:

1
2
ENV DATABASE_URL=postgres://prod-user:prod-pass@prod-db:5432/app
COPY .env.production .env

更推荐:

1
2
3
docker run --rm \
--env-file .env.runtime \
registry.example.com/app/api:1.0.0-a1b2c3d

.env.runtime 本身也不应该提交到公开仓库,应由部署环境或密钥系统管理。

第七步:给镜像打可追溯tag

生产不要只依赖 latest

建议同时保留:

1
2
3
registry.example.com/app/api:1.0.0
registry.example.com/app/api:1.0.0-a1b2c3d
registry.example.com/app/api:prod-20260701-a1b2c3d

tag 至少应该能回答:

  • 这是哪个应用
  • 是前端还是 API
  • 对应哪个业务版本
  • 对应哪个 git commit
  • 是否是某个环境或发布批次

latest 可以用于临时测试,但不应该作为生产回滚依据。

第八步:在CI中构建、测试、推送

一个 GitLab CI 思路可以是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
stages:
- verify
- build
- publish

lint:
stage: verify
script:
- docker build --target lint -t app-lint .
- docker run --rm app-lint

test:
stage: verify
script:
- docker build --target test -t app-test .
- docker run --rm app-test

build-api:
stage: build
script:
- docker build -f apps/api/Dockerfile -t "$CI_REGISTRY_IMAGE/api:$CI_COMMIT_SHORT_SHA" .

publish-api:
stage: publish
script:
- docker push "$CI_REGISTRY_IMAGE/api:$CI_COMMIT_SHORT_SHA"

真实项目还应补充:

  • registry login
  • build cache
  • 多架构构建
  • 镜像漏洞扫描
  • SBOM
  • 制品签名
  • 生产发布审批

但最小可用链路是:先验证,再构建,再推送,最后部署。

第九步:部署和验证

部署动作可以由 compose、Kubernetes、Nomad、Ansible、Helm、Argo CD 或云平台完成。无论工具是什么,验证顺序都类似。

部署前确认:

  • 镜像 tag 是否正确
  • 环境变量是否完整
  • secret 是否存在
  • 数据库迁移是否已执行或有回滚策略
  • 端口和域名是否正确
  • healthcheck 是否可用
  • 日志和 metrics 是否接入

部署后确认:

1
2
curl https://example.com/healthz
curl https://api.example.com/healthz

继续检查:

  • 容器是否持续重启
  • API 日志是否有配置缺失
  • 前端是否能访问正确 API base URL
  • 静态资源路径是否正确
  • 数据库连接池是否正常
  • 关键接口是否通过 smoke test
  • 监控是否收到新版本实例数据

第十步:回滚

Docker 发布的一个重要价值是回滚到旧镜像。

回滚应该切换 image tag,而不是进入容器临时改文件:

1
2
docker compose -f deploy/compose.prod.yml pull
docker compose -f deploy/compose.prod.yml up -d

如果使用 Kubernetes,则通常回滚 Deployment revision 或修改 image tag。

回滚前要确认:

  • 数据库 schema 是否兼容旧版本
  • 配置项是否兼容旧版本
  • 前端资源和 API 是否版本匹配
  • 缓存、队列消息、任务 worker 是否有兼容问题

镜像回滚简单,不代表系统状态回滚也简单。涉及数据库迁移和异步任务时,要提前设计回滚策略。

常见问题

Dockerfile可以同时用于CI和生产吗

可以,但建议用不同 stage 区分目标。

例如 linttestbuild stage 用于 CI,runtime stage 用于生产镜像。这样可以复用依赖安装逻辑,又不会把测试工具和开发依赖带进生产镜像。

前端镜像一定要用Nginx吗

不一定。

常见选择有:

  • Nginx 或 Caddy 镜像
  • Node SSR 服务,例如 Next.js standalone
  • 对象存储加 CDN
  • 云厂商静态站点托管

如果是 SPA,Nginx/Caddy 镜像简单稳定。如果是 SSR,就需要保留 Node runtime。

docker compose能不能作为生产部署

可以用于小规模、单机或内网服务,但要明确它的边界。

如果系统需要滚动发布、自动扩缩容、多节点调度、服务发现、密钥管理、声明式回滚,Kubernetes、Nomad 或云平台会更合适。

生产镜像要不要安装curl

看健康检查方式。

如果 compose 或编排平台的 healthcheck 依赖 curl,镜像里就必须有 curl。也可以改用 wget,或者让应用提供一个内置健康检查命令。

关键原则是:healthcheck 写了什么,镜像里就必须真的能执行什么。

发布负责人检查清单

发布前:

  • .dockerignore 是否排除了无关文件和敏感文件
  • Dockerfile 是否使用多阶段构建
  • runtime image 是否只包含运行所需内容
  • lint/test/build 是否能在 CI 中稳定运行
  • image tag 是否包含版本和 git sha
  • 配置和 secret 是否没有写死在镜像里
  • healthcheck 命令是否真的存在
  • 日志是否输出到 stdout/stderr
  • 前端 API base URL 是否由配置控制
  • 数据库迁移和回滚策略是否明确

发布后:

  • /healthz/readyz 是否正常
  • 容器是否持续运行
  • 日志是否有配置错误
  • metrics 是否上报
  • 关键接口 smoke test 是否通过
  • 前端静态资源和 API 版本是否匹配
  • 旧版本镜像是否仍可回滚

这件事的项目价值

对常规 Web 应用来说,Docker 发布能力体现的是工程交付基本功:

  • 构建可复现
  • 环境可控
  • 镜像可追溯
  • 配置不写死
  • 发布可验证
  • 故障可回滚

比较可信的项目表述是:

我把前端、API service 和 CI 验证流程做成了可复现的 Docker 构建链路,通过多阶段 Dockerfile 区分 lint/test/build/runtime,使用不可变镜像 tag、外部配置、healthcheck 和 smoke test 支撑测试环境或生产环境发布。

这比单纯说“我会写 Dockerfile”更接近真实 Web 工程交付。

背景与问题修正

“AI Native 开发模式”这个说法容易变得空泛。如果只把它理解成“用 AI 写更多代码”,很可能会得到更快的技术债、更大的审查压力和更难追溯的生产事故。

更准确的问题应该是:

如何把 AI 作为受控的开发参与者嵌入软件开发生命周期,并用 DevOps 的反馈、自动化、度量、安全和治理机制,让 AI 生成的产出可验证、可追溯、可交付、可运维。

这篇笔记用于持续研究这个问题:如何在软件开发生命周期中同时追求 AI 自动化效率和工程受控性。

资料快照日期:2026-06-30。后续涉及最新报告、工具能力、政策、模型版本或企业实践时,需要重新查证。

核心判断

当前更稳妥的判断是:

  1. AI 不应被视为替代 SDLC 的捷径,而应被纳入 SDLC 的受控参与者。
  2. AI 会提高需求整理、代码生成、测试草稿、文档、Review 辅助和运维分析的吞吐量。
  3. 吞吐提升后,瓶颈会转移到验证、审查、安全、可追溯、权限控制和生产化。
  4. DevOps 的价值不会因为 AI 降低,反而更重要,因为它提供反馈回路、自动化门禁、交付度量和生产证据。
  5. AI Native 的成熟度不应看“生成了多少代码”,而应看“AI 产出是否能被持续验证、审查、发布、回滚和审计”。

一句话结论:

AI Native 不是让 AI 绕过工程纪律,而是把 AI 产生的不确定性转化为可验证的软件交付证据。

事实基线

截至本次整理,公开资料给出的方向大致一致:

  • DORA 2025 报告强调,AI 更像组织能力的放大器,回报主要来自底层组织系统,而不是工具本身。
  • DORA 关于 AI 张力的研究指出,AI 提升吞吐后,可能增加审查、验证和交付稳定性的压力。
  • GitHub Octoverse 2025 显示,AI 相关开发活动已经进入主流软件生态,LLM SDK 使用和 agentic coding 迹象明显增长。
  • NIST SSDF 要求把安全实践集成进每一种软件开发生命周期。
  • NIST AI RMF 和 Generative AI Profile 提醒,AI 系统需要围绕治理、映射、度量和管理风险来设计。
  • OWASP LLM Top 10 2025 把 Prompt Injection、敏感信息泄露、供应链、过度代理权等列为核心风险。
  • SLSA 和 OpenSSF Scorecard 这类供应链安全实践,正在成为 AI 参与开发后的基础门槛。

这些事实不支持一个简单结论:“AI 越多越好”。它们更支持另一个结论:AI 能力越强,工程控制系统越要强。

AI Native SDLC 框架

可以把 AI Native SDLC 拆成五个关键词:

  • Intent:需求、约束、验收标准、风险等级先结构化。
  • Context:AI 能访问受控的代码、文档、接口、日志、架构决策和规范。
  • Agent:AI 可以生成、修改、测试、审查、总结,但权限分级、行为可审计。
  • Evidence:每个产出都必须有测试、扫描、评审、运行证据支撑。
  • Governance:人负责判断,流水线负责验证,平台负责权限和追踪。

需求阶段

AI 适合做:

  • 汇总用户反馈、issue、客服记录、事故记录和日志摘要。
  • 把自然语言想法转成用户故事、验收标准、边界条件和风险清单。
  • 对需求提出反例,例如权限、数据一致性、异常流、灰度发布、回滚方案。

控制点:

  • 需求不能停留在 prompt。
  • 必须沉淀为 PRD、用户故事、验收标准、接口契约或 issue。
  • 关键需求必须明确非功能要求,例如安全、性能、可观测性、合规、兼容性和回滚。

设计阶段

AI 适合做:

  • 生成 ADR 备选方案。
  • 草拟接口、数据流、状态机、错误处理和威胁模型。
  • 对比不同架构方案的代价。
  • 检查方案是否遗漏安全、降级、可观测、迁移、回滚和运维入口。

控制点:

  • 人类架构师或负责人必须做最终取舍。
  • AI 生成的设计不能直接成为事实,必须经过约束核查。
  • 架构决策要留下 ADR 或设计记录,避免未来只剩一段聊天记录。

编码阶段

AI 适合做:

  • 生成样板代码。
  • 小范围重构。
  • 根据既有模式补全实现。
  • 生成单测草稿、mock、迁移脚本、CLI 工具和文档。
  • 解释陌生代码和调用链。

控制点:

  • PR 要小。
  • 强类型、lint、format、单测、集成测试必须进入默认流程。
  • AI 生成代码不能绕过代码审查。
  • 对安全、权限、支付、生产数据、基础设施、机器人或工业设备控制相关代码,应提高审查等级。

Review 阶段

AI 适合做:

  • 在作者侧提前检查代码风格、测试缺口、潜在 bug 和安全风险。
  • 总结 PR 变更范围。
  • 对比需求验收标准和实际实现。
  • 提醒破坏性变更、迁移风险和回滚遗漏。

控制点:

  • AI Review 不能替代人工 Review。
  • 人工 Review 应聚焦语义正确性、业务后果、架构一致性和风险判断。
  • 不应把 AI 接受率、AI 评论数量或代码行数当作质量指标。

测试阶段

AI 适合做:

  • 生成测试用例草稿。
  • 补充边界条件。
  • 生成 e2e 脚本、契约测试、mock 数据和回归用例。
  • 从事故复盘中提炼回归测试。

控制点:

  • 不能让“AI 生成代码 + AI 生成测试”自证正确。
  • 关键路径的测试 oracle 必须由人或明确规格定义。
  • CI 至少覆盖单测、集成测试、契约测试、e2e、静态检查和安全扫描中的必要部分。

DevSecOps 阶段

AI 时代应把这些能力作为默认门禁:

  • SAST:静态应用安全测试。
  • SCA:开源依赖和漏洞扫描。
  • secret scanning:密钥泄漏扫描。
  • IaC scanning:基础设施即代码扫描。
  • container scanning:镜像漏洞扫描。
  • SBOM:软件物料清单。
  • provenance:构建来源和制品可追溯。
  • signed artifact:制品签名。
  • policy as code:用规则控制发布、权限和例外。

控制点:

  • AI 生成代码进入同一质量门禁。
  • 不能为 AI 代码开“快速通道”。
  • 例外必须记录原因、负责人、过期时间和补偿措施。

发布阶段

AI 适合做:

  • 生成 release note。
  • 总结变更影响。
  • 草拟回滚预案。
  • 分析灰度数据和错误趋势。

控制点:

  • 发布仍应依赖 feature flag、canary、blue-green、自动回滚和审计记录。
  • 高风险发布必须有人审批。
  • AI 可以建议操作,但不应直接绕过流水线变更生产环境。

运维阶段

AI 适合做:

  • 日志摘要。
  • 告警聚类。
  • runbook 推荐。
  • 事故复盘草稿。
  • 根因假设生成。
  • 用户影响范围分析。

控制点:

  • 对生产写操作、删数据、扩缩容、权限变更、设备控制等动作,应默认 human-in-the-loop。
  • AI 的操作建议要进入审计。
  • 事故复盘要区分事实、推断和行动项。

自动化与受控的边界

可以先按风险等级划分 AI 权限:

场景 推荐自动化程度 控制要求
文档摘要、代码解释、测试草稿 保留来源和人工抽查
样板代码、小型重构、非关键工具 中高 小 PR、CI、Review
业务核心逻辑、权限、安全、支付 中低 强制人工审查、测试证据
基础设施、生产发布、数据迁移 审批、回滚、审计
删除生产数据、控制真实设备、改变权限边界 极低 默认禁止自动执行,必须人工确认

一个实用原则:

AI 可以加速低风险、可回滚、可测试的事情;越接近生产状态、真实资产和不可逆操作,越需要人和平台门禁。

度量体系

保留 DevOps 的核心指标:

  • lead time:从提交到上线的时间。
  • deployment frequency:部署频率。
  • change failure rate:变更失败率。
  • MTTR:平均恢复时间。

增加 AI 时代指标:

  • AI 代码返工率。
  • AI 产出缺陷逃逸率。
  • AI 参与 PR 的平均 Review 时长。
  • PR 体积变化。
  • 关键路径测试覆盖率。
  • CI 阻断原因分布。
  • SAST/SCA/secret scanning 阻断数。
  • SBOM 覆盖率。
  • 制品签名覆盖率。
  • provenance 覆盖率。
  • agent 权限违规次数。
  • 生产事故中 AI 参与代码的比例和原因。

谨慎使用这些指标:

  • AI 生成代码行数。
  • Copilot/Cursor/Claude Code 接受率。
  • prompt 次数。
  • commit 数。
  • PR 数量。

这些指标可能说明活跃度,但不能直接说明软件质量和交付价值。

实践路线

2 到 4 周:建立基线

目标:知道当前工程系统的真实状态。

交付物:

  • 当前 DORA 指标快照。
  • Review 时长和 PR 体积统计。
  • CI 失败原因分类。
  • 缺陷逃逸和回滚记录。
  • AI 使用规范初稿。

验收标准:

  • 明确哪些场景允许 AI 参与。
  • 明确哪些数据不能进入外部模型。
  • 明确哪些操作必须人工审批。
  • 至少有一条从需求到发布的证据链样例。

1 到 2 个月:受控试点

目标:选一个低风险项目,把 AI 纳入完整 SDLC。

适合试点的对象:

  • 内部工具。
  • 非核心服务。
  • 文档和测试补全。
  • 可回滚的小功能。

交付物:

  • AI 辅助需求、设计、编码、测试、Review、发布的完整样例。
  • CI/CD 门禁。
  • 安全扫描。
  • PR 模板。
  • Review checklist。
  • 发布记录和回滚方案。

验收标准:

  • AI 参与的 PR 能通过统一门禁。
  • 人工 Review 负担没有失控。
  • 缺陷没有明显上升。
  • 产出可以被追溯到需求、测试和发布记录。

3 到 6 个月:建设 AI Engineering Platform

目标:让 AI 使用从个人技巧变成组织能力。

交付物:

  • 代码知识库和文档索引。
  • 规范库和 prompt 模板。
  • agent 权限模型。
  • 审计日志。
  • 制品 provenance。
  • AI 使用成本和质量仪表盘。
  • 安全和合规策略。

验收标准:

  • 不同团队可以复用同一套 AI 工程规范。
  • AI 产出可审查、可追溯、可度量。
  • 高风险动作默认受控。
  • 平台能回答:谁让 AI 做了什么、用了什么上下文、改了什么、如何验证、何时发布、出了问题如何回滚。

与个人技术方向的关系

对我这种从 Web/full-stack 向工业软件、机器人应用、数字孪生和 AI-assisted operations 转型的人来说,这个方向有现实价值。

更强的定位不是“我会用 AI 写代码”,而是:

我能把 AI、DevOps、工业系统状态、可观测性、权限控制和人机协同流程结合起来,构建可交付、可追溯、可运维的智能软件系统。

这和工业机器人、机器视觉、数字孪生、设备状态平台、AI 运维助手都有连接点。尤其在真实设备和工业现场中,自动化不能脱离状态建模、权限、安全、审计、回滚和人工确认。

后续研究问题

后续需要继续研究:

  • 企业如何定义 AI 生成代码的责任边界。
  • AI agent 在 CI/CD 中应获得哪些最小权限。
  • 如何设计 agent 审计日志和 evidence trail。
  • AI Review 如何避免制造噪音。
  • AI 生成测试如何和人工定义的 oracle 结合。
  • 如何把 NIST SSDF、SLSA、OWASP LLM Top 10 落到日常开发流水线。
  • 如何在工业软件和机器人系统中设计 human-in-the-loop。
  • 如何度量 AI 对质量、交付速度和运维稳定性的真实影响。

持续研究日志

2026-06-30:初始判断

初始结论:AI Native 开发不应被理解成“AI 替代软件工程”,而应被理解成“AI 参与软件工程后,如何通过 SDLC、DevOps、DevSecOps 和治理机制把不确定产出变成可信交付”。

当前最重要的实践问题不是 prompt,而是:

  • 上下文如何受控。
  • 权限如何分级。
  • 产出如何验证。
  • 证据如何保留。
  • 事故如何追溯。
  • 生产如何回滚。

参考资料

背景

以前用 Docker 部署 Jenkins、MySQL 这类成熟 SaaS 服务时,主要工作是:

  • 找到官方镜像
  • 配置端口和 volume
  • 设置环境变量
  • 通过 docker rundocker compose 启动

这种经验很有价值,但它容易造成一个误解:Docker 只是把程序放进镜像里,然后到处运行。

对于工业硬件项目,这个理解不够。以这次项目为例,它不是单纯的 Web 服务,而是 Rust 后端、前端、工业硬件 SDK、机器人 bridge、动态库和现场网络共同组成的系统。Docker 发布的难点不在 Dockerfile 语法本身,而在下面这些东西是否一致:

  • 目标 OS
  • CPU 架构
  • 原生 SDK
  • 动态库路径
  • Cargo feature
  • Python bridge 环境
  • 硬件访问方式
  • 运行时配置
  • 现场网络和权限

先给结论

Docker 镜像不是跨平台安装包。Linux image 运行 Linux binary,Windows image 运行 Windows binary。

当前项目快照中,根目录 Dockerfile 是 Linux 容器镜像,不是 Windows 发布包。它不能直接加载 Windows MechMind SDK DLL,也不能直接复用 Windows 上的 Python/Agilebot bridge 环境。

如果目标环境是:

  • Windows
  • Agilebot
  • MechMind

更稳妥的第一条生产发布路线是 Windows 原生发布包,而不是先走 Docker。

Docker 版本更适合单独作为 Linux 工控机部署线维护,前提是 MechMind Linux SDK、Agilebot bridge、网络发现和硬件访问都已经在容器内验证过。

常见误区

误区一:Docker 可以天然跨平台

Docker 的跨平台能力经常被误解。

容器共享宿主机内核,它不是完整虚拟机。Linux container 使用 Linux 内核,Windows container 使用 Windows 内核。

在 Windows 上使用 Docker Desktop 跑 Linux container,本质上是运行在一个 Linux VM 里,不等于 Windows 原生环境。因此:

  • Linux container 不能直接加载 Windows DLL
  • Linux container 不能直接使用 Windows Python 虚拟环境
  • Windows 上的 USB、GigE 相机发现、网卡广播、厂商服务发现,在 Linux VM 中可能有额外限制

误区二:编译通过就代表运行时可用

工业项目经常依赖原生动态库。编译通过只说明构建阶段找到了头文件、链接库或 feature 配置,不代表运行时一定能找到对应动态库。

例如:

  • MechMind 是厂商 C++ SDK FFI,容器中必须安装对应平台的 SDK 和动态库
  • ONNX Runtime 需要运行时动态库路径,启用 onnx feature 不等于容器内已经能加载 ONNX Runtime
  • Python bridge 需要 Python 版本、依赖包、脚本路径和运行权限一致

误区三:镜像里有程序就能控制硬件

硬件访问还涉及网络、权限和现场拓扑。

相机、机器人、PLC、工控机之间通常依赖固定 IP、网段、广播发现、端口白名单、厂商守护进程和设备驱动。容器部署时,需要额外确认:

  • 容器网络模式是否能访问设备网段
  • 是否需要 host network
  • 是否需要挂载 USB 或设备节点
  • 是否需要厂商 license 或运行时服务
  • 容器内路径是否和配置文件一致

误区四:latest 可以作为生产版本

latest 适合临时测试,不适合生产部署和回滚。

生产版本应该使用不可变 tag,例如:

1
2
0.6.1-mechmind-a1b2c3d
runtime-20260623-mechmind-a1b2c3d

至少应该能从 tag 中看出:

  • 业务版本
  • feature profile
  • git sha

发布前确认

确认发布 profile

当前项目快照中,可以按下面方式理解 profile:

profile 说明
default 当前 Dockerfile 默认,约等于 step + onnx
mechmind 需要启用 step,onnx,mechmind
full 会启用 mechmind + galaxy + realsense + step + onnx,不建议只为了 MechMind 直接用 full
agilebot 不是单独 Cargo feature,但需要 bridge 脚本、Python 环境和网络连通

这里要区分两件事:

  • Cargo feature 决定 Rust 编译哪些能力
  • 运行时依赖决定服务启动后能不能真的连接设备

agilebot-driver 如果是常驻依赖,就不能按 feature 来理解。它真正的部署风险在 bridge 是否能运行、路径是否一致、机器人网络是否可达。

确认镜像目标平台

发布前先明确目标:

  • Linux image:基于 Debian/Rust 构建 Linux binary
  • Windows image:需要 Windows container base、MSVC Rust toolchain、Windows SDK 路径
  • Windows 原生包:输出 .exe、SDK、配置模板和服务安装脚本

当前项目已有的是 Linux image 路线,不等于已经有 Windows Docker 发布路线。

确认 Dockerfile 能力

按这次 AI agent 对项目的描述,当前 Dockerfile 有几个重点:

  • 安装了基础构建依赖和 ca-certificates
  • 没有安装 MechMind SDK
  • 没有安装 Agilebot bridge 的 Python 依赖
  • 没有把 feature 参数做成 ARG
  • 固定执行 cargo build --release --bin runtime-service --bin runtime-cli
  • 不会自动启用 mechmind

如果要让镜像支持不同发布 profile,Dockerfile 通常需要把 feature 做成构建参数:

1
2
3
4
5
ARG CARGO_FEATURES=step,onnx
RUN cargo build --release \
--bin runtime-service \
--bin runtime-cli \
--features "${CARGO_FEATURES}"

如果启用 MechMind,还需要处理 SDK:

1
2
# 示例:具体路径以厂商 Linux SDK 为准
ENV LD_LIBRARY_PATH=/opt/mechmind/lib:${LD_LIBRARY_PATH}

这不是固定答案,只是提醒:原生 SDK 必须进入镜像或通过受控方式挂载,不能只改 Rust feature。

Docker 发布流程

1. 冻结发布信息

发布负责人先记录:

  • git commit sha
  • 业务版本号
  • Cargo feature profile
  • 目标 OS
  • 目标 CPU 架构
  • SDK 版本
  • 配置文件版本
  • 是否包含 Agilebot bridge
  • 是否包含 MechMind SDK

这一步的目的不是写文档好看,而是为了出问题时能复现。

2. 构建镜像

基础构建:

1
docker build -t microi-runtime:<git-sha> .

启用 MechMind 的构建可以类似:

1
2
3
4
docker build \
--build-arg CARGO_FEATURES="step,onnx,mechmind" \
-t microi-runtime:0.6.1-mechmind-<git-sha> \
.

如果项目使用 GitLab CI,生产构建应优先使用 CI job,而不是个人电脑手工构建。CI 可以保证构建环境、镜像 tag、推送路径和日志可追溯。

3. 推送 registry

CI 当前会推 GitLab registry:

1
2
${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}
${CI_REGISTRY_IMAGE}:latest

实际部署时不要只依赖 latest。建议至少保留:

  • git sha tag
  • 业务版本 tag
  • feature profile tag

Harbor 可以作为基础镜像缓存或企业镜像仓库,但不改变一个原则:生产部署要使用不可变 tag。

4. 准备部署配置

容器通常需要暴露:

1
2
50051 gRPC
50052 HTTP / WebSocket / metrics

建议挂载配置:

1
/etc/microi/runtime.toml

建议挂载运行数据和日志:

1
2
/var/run/microi
/var/log/microi

常见环境变量:

1
2
MICROI_CONFIG=/etc/microi/runtime.toml
RUST_LOG=info,runtime_service=debug

配置文件要把路径写成容器内路径,而不是宿主机路径。

5. 启动服务

可以通过 compose 或现场部署系统启动。重点不是命令形式,而是启动前确认:

  • image tag 是否正确
  • config 是否是目标现场配置
  • volume 是否挂载到正确路径
  • 网络模式是否能访问硬件设备
  • SDK 动态库是否能被加载
  • bridge 脚本是否在容器内可执行

6. 验证服务

验证要从只读能力开始,不要一上来发运动指令。

基础检查:

1
curl http://host:50052/api/status

继续确认:

  • 日志里 enabled features 是否包含目标 profile,例如 mechmind
  • 设备列表中 Agilebot 和 MechMind 是否注册成功
  • MechMind 是否能建立只读连接
  • MechMind 是否能执行单帧 capture
  • Agilebot 是否能读取状态
  • WebSocket 或 HTTP 状态是否正常刷新
  • metrics 或日志中是否出现 SDK 加载错误

机器人验证顺序建议:

  1. 服务启动
  2. 只读状态读取
  3. 设备连接状态
  4. 坐标、限位、急停状态确认
  5. 人工确认安全环境
  6. 再考虑低风险运动命令

7. 回滚

回滚应该切换 image tag,不要临时改容器内部文件。

回滚后重新验证:

  • /api/status
  • 设备注册
  • MechMind capture
  • Agilebot read-only state probe
  • 日志中的 feature profile 和 SDK 版本

数据卷和现场配置一般不要跟着镜像回滚,除非本次发布明确包含配置 schema 变更,并且准备了配置回滚方案。

当前项目暴露出的维护点

这次问答中提到两个很典型的 Docker 漂移问题:

compose 引用的 Dockerfile 路径漂移

deploy/docker-compose.yml 引用的是:

1
deploy/Dockerfile

但仓库实际只有根目录 Dockerfile。

这类问题说明 compose 文件和真实构建入口已经漂移。发布负责人需要决定:

  • 把 Dockerfile 移到 deploy/
  • 修改 compose 指向根目录 Dockerfile
  • 或者统一由 CI 构建镜像,compose 只负责拉取镜像

healthcheck 依赖镜像中不存在的工具

compose healthcheck 使用了 curl,但 runtime image 当前没有安装 curl

解决方式有两类:

  • 在 runtime image 中安装 curl
  • 改用镜像内已经存在的健康检查工具或内置二进制

健康检查不是装饰项。工业软件部署中,健康检查会影响重启、告警、回滚和现场判断。

Windows + Agilebot + MechMind 的推荐路线

如果目标是 Windows 现场,并且需要 Agilebot + MechMind,建议先做 Windows 原生发布包:

1
2
3
4
5
6
7
8
9
10
runtime-service.exe
runtime-cli.exe
MechMind Windows SDK
Agilebot bridge
Python 运行环境或依赖锁定
runtime.toml 配置模板
Windows service 安装脚本
日志目录
版本说明
回滚说明

这条路线更符合 Windows 厂商 SDK 和现场调试习惯。

Docker 版本可以作为 Linux 工控机部署线维护,但要单独验证:

  • MechMind Linux SDK
  • Agilebot bridge 的 Linux 可用性
  • 容器网络访问设备的稳定性
  • 动态库路径
  • Python 依赖
  • 权限、license 和现场网段

不要把 Windows 原生部署和 Linux Docker 部署混成一个发布方案。

发布负责人检查清单

发布前:

  • 是否明确目标 OS 和 CPU 架构
  • 是否明确 Cargo features
  • 是否明确 SDK 版本
  • 是否明确 image tag
  • 是否避免只用 latest
  • 是否确认 Dockerfile 安装了运行时依赖
  • 是否确认 compose 的 Dockerfile 路径正确
  • 是否确认 healthcheck 命令可用
  • 是否确认配置文件使用容器内路径
  • 是否确认硬件网络可达

发布后:

  • 服务是否启动
  • /api/status 是否正常
  • 日志中 feature profile 是否正确
  • 动态库是否加载成功
  • MechMind 是否能只读连接和 capture
  • Agilebot 是否能只读读取状态
  • 前端或 API 是否能看到设备状态
  • 是否有明确回滚 tag

这件事的项目价值

对工业软件和机器人应用开发来说,Docker 发布能力不是会写几行 Dockerfile,而是能把软件、SDK、设备、网络、配置和回滚组织成一条可靠发布链路。

这类能力可以证明的不只是 DevOps 基础,而是系统集成能力:

  • 知道容器边界
  • 知道原生 SDK 的限制
  • 知道硬件访问不是普通 Web API
  • 知道 feature、配置和运行时依赖要对齐
  • 知道发布和回滚要可追溯

这比单纯说“我部署过 Docker”更接近工业机器人项目里真正需要的工程能力。

系统概述

比例阀用于工业系统中实现气压或流量的连续可调控制。典型应用包括:

  • 机器人夹爪力控
  • 打磨恒力控制
  • 点胶压力控制
  • 张力控制系统

比例阀系统的关键不是简单输出一个模拟量,而是要把设定值、现场反馈、安全联锁、报警和状态机组织成一个可靠的控制闭环。

系统架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
       上位机 HMI / SCADA
|
参数配置 / 显示
|
PLC
+---------+---------+
| | |
比例阀控制 状态机 报警逻辑
|
模拟量 / 总线
|
比例阀
|
气路系统
|
压力传感器反馈
|
PLC

职责划分

PLC 负责实时控制和安全:

  • 压力闭环逻辑
  • IO 控制
  • 状态机执行
  • 安全联锁
  • 报警判断
  • 设备启停条件

上位机负责非实时功能:

  • 压力设定和配方管理
  • 数据展示
  • 历史记录
  • 报警显示和查询
  • 参数管理

核心原则:

PLC 做判断、控制和安全;上位机做配置、展示和记录。

上位机不能承担实时判断,原因是网络延迟、操作系统非实时、进程可靠性和通信链路都不可作为安全控制的基础。

比例阀控制模式

开环控制

1
PLC -> 模拟量 -> 比例阀 -> 气压

特点:

  • 无反馈
  • 成本低
  • 精度一般
  • 适合要求不高或负载变化很小的场景

闭环控制

1
2
3
4
5
PLC -> 设定压力 -> 比例阀
|
压力传感器
|
PLC反馈

特点:

  • 可控性强
  • 精度更高
  • 能抵抗负载和气路波动
  • 是工业比例阀系统的主流设计

比例阀控制本质:

1
PLC设定值 -> 比例阀 -> 气压 -> 反馈 -> PLC闭环

PLC 内部变量模型

1
2
3
4
5
6
7
8
9
PressureCmd       : REAL;   // 目标压力
PressureActual : REAL; // 实际压力

PressureError : REAL;

PressureOK : BOOL;
PressureLowAlarm : BOOL;
PressureHighAlarm : BOOL;
PressureStable : BOOL;

基础判断逻辑:

1
2
3
4
5
6
7
8
9
10
11
PressureError := ABS(PressureCmd - PressureActual);

PressureOK :=
(PressureError < 0.05)
AND (PressureActual > MinPressure);

PressureLowAlarm :=
PressureActual < MinPressure;

PressureHighAlarm :=
PressureActual > MaxPressure;

工业现场通常不只看瞬时误差,还要判断稳定时间。例如:

1
2
3
IF ABS(PressureCmd - PressureActual) < 0.05 FOR 200ms THEN
PressureStable := TRUE;
END_IF;

实际 PLC 程序里通常会用定时器功能块实现这个逻辑,避免信号抖动导致状态频繁切换。

上位机数据模型

上位机可以通过适配层把 PLC 数据整理成更适合 UI 或业务逻辑使用的结构:

1
2
3
4
5
6
7
8
9
10
struct PressureUnit
{
float pressureCmd;
float pressureActual;

bool pressureOK;
bool lowAlarm;
bool highAlarm;
bool stable;
};

上位机主要负责:

  • 显示状态
  • 修改设定值
  • 保存配方
  • 显示报警历史
  • 记录趋势数据

上位机可以提示和记录异常,但不要把安全联锁和实时报警生成放在上位机。

PLC 信号表设计

输入信号

名称 类型 说明
AI_PressureActual REAL 压力传感器反馈
DI_SystemReady BOOL 系统就绪
DI_EStop BOOL 急停

输出信号

名称 类型 说明
AO_PressureCmd REAL 模拟量输出
DO_EnableValve BOOL 阀使能

PLC 内部变量

名称 类型 说明
PressureCmd REAL 目标压力
PressureActual REAL 实际压力
PressureOK BOOL 压力正常
PressureAlarm BOOL 总报警

上位机交互信号

名称 类型 说明
HMI_PressureSet REAL 上位机设定压力
HMI_PressureActual REAL 显示反馈压力
HMI_AlarmCode INT 报警码

状态机设计

比例阀控制不应该只靠几个布尔量堆逻辑,建议明确状态机:

1
2
3
4
5
6
7
8
9
10
11
12
13
INIT
|
READY
|
PRESSURIZE
|
STABLE
|
WORKING
|
RELEASE
|
ERROR

INIT

系统初始化和 IO 检查。

典型动作:

  • 清空报警或等待复位
  • 检查急停、气源、传感器状态
  • 初始化输出为安全值

READY

等待启动,压力归零或处于安全待机压力。

典型条件:

  • 系统就绪
  • 无报警
  • 阀使能条件满足

PRESSURIZE

比例阀逐步升压。

进入或保持条件:

1
PressureActual < PressureCmd

工程上通常还要加入升压超时判断,避免气路泄漏或阀异常时一直等待。

STABLE

压力达到目标并保持稳定。

稳定条件:

1
ABS(Error) < threshold for 200ms

只有进入稳定状态后,才允许后续工艺动作启动。

WORKING

执行工艺动作,例如机器人夹爪动作、打磨、点胶或张力控制。

这个阶段仍然需要持续监控压力范围,异常时进入 ERROR。

RELEASE

泄压和复位。

典型动作:

  • 关闭阀使能或输出泄压设定
  • 等待压力降到安全范围
  • 返回 READY

ERROR

压力异常或安全条件不满足时进入错误状态。

常见触发条件:

  • 压力过低
  • 压力过高
  • 传感器异常
  • 超时未达到压力
  • 急停或安全联锁断开

ERROR 状态中 PLC 应执行联锁停止,并等待人工确认或复位条件满足。

报警设计

报警应由 PLC 生成,上位机只做显示、记录和查询历史。

报警类型 说明
PressureLow 压力不足
PressureHigh 超压
PressureTimeout 未达到目标
SensorFault 传感器异常

基础报警逻辑示例:

1
2
3
IF PressureActual < MinPressure THEN
AlarmLow := TRUE;
END_IF;

实际工程中还要考虑:

  • 报警延时,避免瞬时波动误报
  • 报警锁存,避免故障消失后历史不可追溯
  • 报警复位条件,避免未处理故障被直接清除
  • 报警码统一映射,方便上位机显示和日志记录

关键设计原则

实时逻辑必须在 PLC:

  • 压力判断
  • 联锁
  • 状态机
  • 报警生成
  • 阀输出控制

上位机不能做实时判断:

  • 网络有延迟
  • 操作系统非实时
  • 通信可能中断
  • UI 线程和业务线程不适合作为安全控制依据

工业系统的分层关系:

1
2
控制层 PLC = 决策 + 安全 + 执行
上位机 = 管理 + 可视化 + 配置

工程经验总结

比例阀系统本质是压力版伺服系统:PLC 负责闭环判断、状态机和安全联锁,上位机只负责参数、配方、显示和记录。

设计时最重要的是把实时控制闭环留在 PLC,把人机交互和数据管理放到上位机,不要让上位机成为安全链路的一部分。