第二部 原理篇:规则驱动的智能体设计

第 16 章:运行时基础设施:横切能力的工程原理

16.1 工程问题的本质:横切而非纵切

可观测性、缓存、事件、存储、认证、调度——这些能力不属于任何业务环节,而是贯穿所有环节的横切面。横切能力最常见的两种失败:缺失(出了问题无法定位)与反客为主(横切能力成为系统的主干,业务逻辑围着基础设施转)。

设计原理: 横切能力的工程形态是"框架提供,业务选用,缺省降级"。框架提供统一实现(追踪上下文、结构化日志、缓存、事件总线、存储抽象、SSE、认证、调度、模块开关),业务按需注入真实后端,未注入时降级到最小可信实现。横切能力是"运行的底座",不是"业务的主干"。

16.2 可观测性:追踪与日志的一体化

链路追踪以 contextvars 提供线程安全的统一上下文;日志统一附加 trace_id;审核链的 chain_id 复用 trace_id。

原理: 可观测性的本质是关联——一次请求穿过审核链、智能体、模型、数据库、日志,若每一环各记各的 ID,故障时就无法还原全貌。单一 ID 贯穿使"一次请求的一生"可以被重放:从入口到审核链到落库,任何一个环节的异常都能沿 ID 定位。可观测性不是"多打日志",而是"一条链、一个 ID、一份可重放的时间线"。

16.3 事件驱动:解耦与顺序性

事件总线基于 Redis Streams(未装 redis 降级内存 pub/sub)。通知事件的发布方与消费方解耦:协调器发布审批事件,业务侧订阅落库。

原理: 事件驱动的价值是发布方与消费方的生命周期解耦——协调器不需要知道"通知最终存进哪张表",消费方不需要知道"事件从哪个流程来"。但解耦引入了顺序性问题:失效与创建两个事件若分属不同主题,消费方可能先处理创建、后处理失效,导致待办数量错误。

框架将失效合并进创建事件的载荷(expire_before 标志),消费 handler 单线程内先失效旧待办再创建新待办——解耦的是模块,不是顺序。跨主题的顺序性风险必须由设计消除。

16.4 降级与容错:每一级能力都有最小可信实现

基础设施层普遍遵循"可选依赖 + 内存/本地降级":Redis 未装降级内存、boto3 未装降级本地文件系统(含路径穿越防护)、调度器 DB 不可用降级内存记账。

原理: 降级体系回答"外部依赖失效时系统还能做什么"。关键原则:降级到最小可信实现,而非静默失败。内存降级使会话、缓存、事件在依赖失效时仍可运行(虽然不持久);本地文件系统使存储可用(虽然不跨机)。调度器降级内存记账允许重启后重复触发(同日去重降级为内存判断)——这一已知风险被显式记录,而非被掩盖。

16.5 认证:身份与授权的分离

认证模块实现 JWT 签发/校验(标准库实现,不依赖第三方库),认证与授权分离:认证回答"你是谁",授权(第 12 章)回答"你能做什么"。模拟用户源受 DEBUG 门控,正式模式恒返回 None。

原理: 认证与授权的分离是安全设计的基本边界:认证失败时应 fail-closed(不因模拟用户源绕过),授权失败时应 fail-closed(不因权限缺失静默放行)。DEBUG 门控尤其关键——调试后门只能存在于调试模式,模拟用户源在正式模式不可用,防止"为了测试方便"成为生产入口。

16.6 调度与模块开关:运行期的编排

任务调度器(进程内守护线程,零第三方依赖):任务注册、DB 持久化配置、同日去重、异常隔离(处理器异常不中断循环)。模块开关管理模块启用状态,关闭时自动降级返回统一响应。

原理: 调度器的关键设计是配置与代码分离 + 异常隔离:调度时间/启停存配置(管理员改库即可调整),处理器异常不拖垮调度循环(隔离失败)。模块开关解决"业务模块的发布节奏"——某个模块可独立启停而不影响其他模块,是模块独立性的运行期表达。

16.7 可观测性与事件投递的深度设计

16.7.1 日志的结构化 schema

结构化日志的价值不在"是 JSON",而在字段语义可消费。专家级的日志 schema 设计有三个层次,见表 16-1:

表 16-1 可观测性的字段分层

层次 字段 用途
关联层 trace_id、span_id、chain_id 跨环节关联(检索定位)
语境层 event、action、agent_name、user_id 语义过滤(什么类型的事件)
内容层 input/output/tokens/duration 详情分析(性能、质量)

原理: 日志采集(ELK/Loki)的可查询性取决于 schema 的稳定性——字段名、类型、缺省语义一旦漂移,检索表达式即失效。因此结构化 schema 本身需要治理:新增字段须兼容演进(追加而非改名)、敏感字段在 schema 层声明脱敏。日志是给机器读的,schema 是日志的契约。

16.7.2 追踪的采样策略

全量追踪在高流量下的成本(存储、IO、敏感数据暴露面)不可忽视。采样的工程权衡,见表 16-2:

表 16-2 trace 采样策略

策略 语义 适用
全量 每条请求都记录 trace 低流量 / 合规强制场景
固定采样 按比例抽样 流量稳定、求统计特征
头部采样(基于入口) 入口统一决策 保证一条请求各环节一致取舍

原理: 追踪的价值在"异常时能定位",而非"每条都存"。全量存储的价值衰减与成本累积不成比例——大多数正常请求的 trace 永远不会被查看。框架以 trace_id 全量生成、存储按业务策略取舍为默认:ID 永远可关联,存储按需取舍。

16.7.3 事件投递语义:at-least-once 与幂等消费

事件总线(Redis Streams)的投递语义决定了消费方必须如何写:Redis Streams 消费组默认 at-least-once(至少一次)——消息可能被重复投递。这意味着消费方必须幂等:重复处理同一事件不应产生重复副作用(重复创建待办、重复失效通知)。

原理: 事件驱动的"恰好一次"(exactly-once)在分布式语义下代价高昂(需协调协议),工程上常用"at-least-once + 幂等消费"替代:投递方保证不丢,消费方保证重放无害。幂等的实现要点:消费处理以事件 ID 去重、状态更新为幂等操作(置位而非累加)、副作用有唯一键约束。投递语义决定消费方的正确性要求——这是事件驱动架构的核心契约。

16.8 失败模式与权衡

表 16-3 运行时基础设施的失败模式与权衡

失败形态 根因 设计应对
跨层日志无法关联 各环各记 ID trace_id 单一贯穿 + chain_id 复用
事件顺序错乱 跨主题顺序性 合并进载荷 + 单线程消费
依赖失效全线不可用 强依赖外部服务 可选依赖 + 内存/本地降级
调试后门泄漏到生产 模拟源无门控 DEBUG 门控,正式模式 fail-closed

权衡: 内存降级以"不持久"为代价换取"可用";事件解耦以"顺序需设计"为代价换取"模块独立"。降级行为的已知限制(如重启后重复触发)被显式记录,保证降级路径可预期。

16.9 可训练配置的运行时加载:缓存、回退与白名单护栏

可训练化(第 14 章)落地为运行时的一套横切加载模式,供各生成端复用:DB 优先 → 配置回退 → 缓存 三态。

get_id_specs 读 business_rules(ID-SPECS),带 5s TTL 缓存,并在返回前校验 rule_hash 完整性,DB 异常/被篡改时 fail-closed 回退硬编码兜底集;load_numbering_prefix 读 ENTITY_NUMBERING,以 60s 缓存命中关键字前缀;build_number 的模板在三道护栏(唯一性段、整体字符集、未识别占位符)内解析,否则回退 fallback_fn。

设计原理: 横切配置加载的统一目标是"改动不重启即生效、而未改动时绝不偏离"。缓存 TTL 换取热更新,回退层换取孤立性(任一环失效都不至于崩坏),白名单护栏换取确定性——三个机制各回答一个方面,共同构成惰性加载的完整保障。

失败防范: 缓存失效窗口内读到旧值是可接受的(下一周期收敛);但这绝不代表可以跳过 rule_hash 校验——校验缺失时"可训练"退化为"可注入",是 16.4 节降级原则在配置层的红线。

16.10 实现剖析:事件总线的统一契约与两级降级

本节从 runtime/event_bus.py(388 行)摘取两段,印证 16.3 节的解耦与顺序性、16.4 节的降级容错。

16.10.1 统一契约:publish/subscribe 抽象 + Redis Stream 实现

class EventBus:
    """事件总线抽象基类:定义所有实现必须遵循的统一契约"""
    def __init__(self, config):
        self._config = config or {}

    def publish(self, topic, message):
        """发布事件(topic 如 order_created),返回事件 ID"""
        raise NotImplementedError

    def subscribe(self, topic, handler):
        """订阅事件:注册处理器,主题事件发布时被调用"""
        raise NotImplementedError

    def unsubscribe(self, topic, handler): ...
    def close(self): ...


class RedisStreamBus(EventBus):
    """Redis Streams 实现:XADD 写入 / XREAD 阻塞消费"""
    def publish(self, topic, message):
        event_id = message.get("event_id", str(uuid.uuid4()))
        timestamp = message.get("timestamp", now_iso())
        if self._redis is not None:                 # Redis 模式:XADD
            stream_name = self._stream_name(topic)
            self._redis.xadd(stream_name, {
                "event_id": event_id, "topic": topic, "timestamp": timestamp,
                # ... payload 字段
            })
        # ... 内存模式:同步触发已注册处理器
        return event_id

    def subscribe(self, topic, handler):
        with self._lock:
            self._handlers.setdefault(topic, []).append(handler)
            # ★ double-checked locking:并发订阅同一 topic 不重复起消费线程
            if self._redis is not None and topic not in self._consumer_threads:
                stop_event = threading.Event()
                thread = threading.Thread(
                    target=self._consume_loop, args=(topic, stop_event), daemon=True)
                self._consumer_threads[topic] = (thread, stop_event)
                thread.start()

这段代码印证 16.3 节:解耦的前提是"契约稳定 + 实现可换"。业务侧只依赖 EventBus 抽象(publish/subscribe),Redis Streams 只是其中一种实现——这使得"用内存模拟总线跑测试"与"用 Redis 跑生产"共享同一份业务代码。

Redis Streams 相较简单 pub/sub 的关键优势是持久化 + 消费位点:消息写入 Stream 后即使消费者未在线也不会丢,XREAD 从位点续读保证了顺序性。消费线程用 double-checked locking 启动,避免并发订阅时重复拉起线程。

16.10.2 两级降级:Redis 缺失或连不上都不阻断启动

def _init_redis(self):
    """初始化 Redis 连接,未安装或连接失败时降级为内存模式"""
    if not _REDIS_AVAILABLE:                       # 第一级:依赖未安装
        logger.warning("redis 模块不可用,使用内存 pub/sub 模拟事件总线")
        return
    try:
        self._redis = _redis_module.Redis(
            host=cfg.get("host", "127.0.0.1"), port=cfg.get("port", 6379),
            db=cfg.get("db", 0), password=cfg.get("password"),
            ssl=cfg.get("tls", False), decode_responses=True,
            socket_timeout=5, socket_connect_timeout=5)
        self._redis.ping()                         # 主动探活
    except Exception as e:                         # 第二级:连接失败
        logger.warning("Redis 连接失败 (%s),降级为内存 pub/sub", e)
        self._redis = None                         # 降级:内存模式

这段代码印证 16.4 节:"每一级能力都有最小可信实现"不是口号,而是构造函数里的两条分支。第一级降级处理"依赖未安装"(_REDIS_AVAILABLE 为假),第二级处理"装了但连不上"(ping() 抛异常)。

两级都落到 self._redis = None,此后 publish/subscribe 自动走内存路径——系统在没有任何中间件的环境下仍能启动并完成进程内事件投递。注意 ping() 主动探活:只在构造时"试连一次"而非等到首次发布才暴露问题,把故障发现提前到启动阶段。

16.11 本章小结