1. 先把“感知”这件事说清楚:它到底在智能体里干什么
很多人一上手做 AI智能体感知模块,第一反应是“不就是把用户说的话塞给大模型吗”。我最早也是这么想的,直到一个接客服工单的场景把我打醒:用户发来一张截图、一段语音转写的错别字文本、外加一个被截断的订单号,模型给出的结论看起来头头是道,实际上订单号是错的,图片里的关键信息一个字都没读到。那次之后我才明白,智能体的能力上限,往往不是被“大脑”卡住的,而是被“眼睛和耳朵”卡住的。感知模块就是智能体的感官层,它负责把外部世界里乱七八糟的信号,转换成内部推理可以放心消费的结构化事实。
这篇文章想讲清楚的就是这一层。它适合正在做智能体落地的工程同学,也适合产品同学用来判断“这个需求到底该由感知层解决,还是该由决策层解决”。我会从职责边界讲起,讲到架构选型、参数计算、代码骨架、排查表,尽量把能直接抄的部分写细。里面涉及的目录、代码、日志都是我自己搭的一套模拟项目里的东西,不是某个线上系统,但思路是可以直接搬的。
1.1 感知模块的职责边界:它不该做决策
我见过最常见的架构错误,是把“判断”塞进感知层。比如在解析用户消息时,顺手让模型输出一个意图标签,然后决策层直接拿这个标签去执行动作。短期看很省事,长期一定出问题,因为感知层的错误会被下游无限放大,而且没法定位——你分不清是“没看清”还是“想错了”。
我的做法是把边界划得很硬:感知层只回答“世界现在是什么样”,不回答“我该做什么”。具体到输出,它只产出三类东西:原始事实(文本、图片描述、表格单元格)、归一化结果(时间、金额、编号、实体)、不确定性标注(哪些字段是猜的、置信度多少、缺了什么)。意图判断、工具选择、回复措辞,全部留给决策层。这样切的好处是,感知层可以独立做回归测试,可以用固定样本集衡量准确率,出了问题也能一眼看出是“看错”还是“想错”。
还有一个隐性好处:成本可控。感知层的调用是高频且模式化的,可以用小模型、规则、缓存去扛;决策层才是低频高价值的地方,用好模型才划算。混在一起写,等于每次看一眼图片都要付一次推理的钱,账算不过来。
1.2 一个最小可用的感知流水线长什么样
我习惯把感知拆成五段,顺序固定,任何一段都可以替换实现,但不要随意调换顺序:
- 接入:拿到原始输入,判断类型(文本、图片、音频转写、结构化回调、工具返回值)。
- 归一化:统一编码、裁剪超长内容、修复格式、抽出可解析的时间与数字。
- 抽取:按预设 Schema 提取字段,文本走抽取提示词,图片走版面识别,工具返回值走字段映射。
- 校验:做类型、范围、业务规则三重检查,不合格的字段标成缺失而不是硬填。
- 封装:产出一条感知事件,带时间戳、来源、置信度、原文引用,写进上下文或记忆。
这五段里,最容易被忽略的是第 4 段校验。很多团队把抽取结果直接往下传,模型输出一个“2024-13-45”也照单全收,后面所有环节都在错误数据上跑。校验层的成本几乎为零,收益却极大,属于必须做的部分。
1.3 为什么我坚持先做“输入归一化”再谈多模态
多模态听起来很吸引人,但我实际做下来,八成的线上问题出在归一化,而不是模态不够多。举个具体例子:同一张发票,手机拍的、扫描仪扫的、微信压缩过的,三者清晰度差一大截;同一段用户描述,有的带全角标点,有的带零宽字符,有的把数字写成中文。这些差异不处理,后面接再强的模型也是在赌运气。
归一化要做的判断其实很朴素:这段内容能不能被程序可靠地切分?长度有没有超过上下文预算?编码是不是统一?里面的数字和日期有没有歧义?我一般会在接入层做一次“体检”,把体检结果作为元数据带下去。后面所有环节都能看到“这条输入是低质量图片”“这条文本被截断过”,从而决定是重试、降级还是直接向用户要补充信息。这比在最后一步让模型自己猜“我可能没看清”要可靠得多。
2. 感知模块的整体设计与选型考量
2.1 三种常见架构的取舍:直连式、网关式、事件式
我前后试过三种组织方式,各自适用面差别很大,选错了后期改造成本很高。
| 架构形态 | 典型做法 | 优点 | 代价 | 适合场景 |
|---|---|---|---|---|
| 直连式 | 每个智能体自己调解析逻辑 | 起步快,代码少 | 逻辑重复,难统一治理 | 单场景 Demo、验证期 |
| 网关式 | 所有输入先过一层感知服务 | 规则集中,可观测,好做缓存 | 多一跳延迟,需维护服务 | 多智能体共用、要上线 |
| 事件式 | 输入变化触发感知事件流 | 支持增量、支持异步 | 复杂度高,调试麻烦 | 长任务、持续监听型智能体 |
直连式我在做原型时用得最多,一上午就能跑通。但只要场景超过两个,你就会发现同一套“日期归一化”写了三遍,而且三遍的规则还不一样,问题排查时非常痛苦。网关式是我目前的主力选择,它把归一化、校验、缓存收拢到一处,代价是增加一跳延迟,实测同一机房内这一跳通常在几十毫秒量级,对绝大多数对话场景无感。
事件式适合另一类需求:智能体不是被用户一句话叫醒,而是持续盯着某个数据源。这时候感知不再是“函数调用”,而是“订阅 + 变更检测”。我一般会先用网关式把单次感知跑顺,等确实出现“需要持续观察”的需求,再把网关改造成可发布事件的形态,而不是一上来就上事件流。
2.2 为什么把“解析”和“理解”拆成两层
解析是确定性的活:切分、解码、提取字段、补全格式,这些能用规则和轻量模型做,结果可复现。理解是不确定性的活:判断这段话在说什么、图里这个人有没有戴安全帽、这份合同的关键条款风险在哪,必须用大模型。
拆开的好处体现在三个地方。一是缓存:同一张图片的解析结果可以按内容哈希缓存,理解结果可以按“解析结果 + 提示词版本”缓存,命中率很高。二是降级:模型服务抖动时,解析层照常工作,只是理解层降级为返回“未识别”,智能体至少能说一句“我收到了,但暂时读不出内容”,而不是整条链路断掉。三是评测:解析层用精确匹配就能测,理解层才需要人工标注和模糊评分,两套评测体系分开维护,谁退化了很清楚。
注意:拆分不是让你写两套完全独立的工程。解析层的输出必须是理解层的输入,中间用一份明确的 Schema 交接,别搞成两个团队各定义一套字段。
2.3 Schema 优先:让感知输出可被程序消费
我现在的习惯是,动手写抽取提示词之前,先把 Schema 定死。Schema 不是给模型看的装饰,它是感知层和决策层之间的合同。字段名、类型、是否必填、取值范围、缺失时怎么表示,全部写清楚。
一个够用的 Schema 大概长这样:
from typing import Literal, Optional from pydantic import BaseModel, Field class Entity(BaseModel): kind: Literal["order", "person", "product", "time", "amount"] raw: str = Field(description="原文片段,不做任何改写") normalized: Optional[str] = Field(default=None, description="归一化后的值") confidence: float = Field(ge=0, le=1) source: Literal["text", "image", "audio", "tool_result"] class PerceptionEvent(BaseModel): event_id: str observed_at: str modality: list[str] entities: list[Entity] missing_fields: list[str] quality_flags: list[str] = Field(default_factory=list) raw_excerpt: str这里有两个设计决定值得说。raw和normalized分开存,是为了保留可追溯性,归一化出错时能回看原文,而不是只剩一个被改过的东西。missing_fields单独列出来,比在字段里塞空字符串好得多,决策层可以据此直接决定“要不要追问用户”,不用去猜空值代表什么。
2.4 成本和延迟的账:先算清楚再动手
感知层的调用量通常是决策层的好几倍,钱主要烧在这里。我一般按下面的方式估:
假设单次对话平均产生 6 条感知输入(用户消息、两次工具返回、一次图片、两次补充说明),日活 5000 轮对话,那就是每天 3 万次感知调用。如果每次都用大模型做全量抽取,按输入 1200 token、输出 200 token 估算,日 token 量约 4200 万,成本很容易失控。
我的优化顺序是这样的:先用规则和正则吃掉能确定的部分(编号、金额、日期),这部分几乎零成本;再用小模型做分类和字段定位,只对必要的片段调大模型;最后给理解层加缓存,同一份解析结果在短时间内重复出现时直接复用。实际跑下来,大模型调用量能压到原来的三分之一左右,延迟也从秒级降到几百毫秒。这个账必须在设计阶段算,等上线了再优化,改动面会大很多。
3. 核心环节的实现细节与实操要点
3.1 输入归一化:编码、格式、长度的三道过滤
第一道是编码与字符。我会统一转 UTF-8,去掉零宽字符和不可见控制符,把全角数字、全角标点转成半角。这一步看起来琐碎,但实测下来能明显减少抽取错误,尤其是从聊天软件复制粘贴过来的文本,经常夹带奇怪的空格和软连字符。
第二道是格式修复。纯文本要处理换行混乱、列表符号丢失;表格要还原行列关系;图片要先判断分辨率和倾斜角度,低于阈值的直接标为低质量,进入降级分支而不是硬着头皮识别。我给图片定的下限是短边不低于 720 像素,低于这个值就提示“图片过小,请重拍或上传原图”,比让模型去猜要诚实。
第三道是长度裁剪。这里必须算清楚预算。假设模型上下文是 32K token,我要留 8K 给系统提示词和输出,感知层可用大约 20K。中文大致按 1 字约 1.5 token 估,那正文上限大约 1.3 万字。超长文档必须分块,分块策略我倾向于按语义边界切(章节、段落、表格行),而不是按固定字数硬切,硬切最容易把一句话和它的主语切散。
提示:裁剪时一定要记录“被裁掉了多少”,写进
quality_flags。后面如果发现模型答非所问,第一件事就是查这条标志,而不是怀疑模型变笨了。
3.2 文本感知:抽取提示词骨架与校验闭环
文本抽取的提示词我一般写成四段式:角色与任务、字段定义、输出格式、边界规则。重点在第四段,也就是“遇到什么情况该认输”。很多提示词只讲要什么,不讲不要什么,结果模型遇到缺字段时会自己编一个看起来合理的值,这是最危险的失败模式。
边界规则我会明确写:原文没有的信息一律填 null,不要推测;数字必须原样抄录并在 normalized 里给出转换结果;不确定的字段把 confidence 调低;无法判断类型时归到unknown而不是硬套最像的那个。这些规则听起来啰嗦,但确实能大幅降低“一本正经胡说”的比例。
校验层是第二道保险,我按三个层次做:
- 类型校验:金额必须是数字,时间必须能解析成时间戳,编号必须符合业务格式(比如长度和前缀)。
- 范围校验:金额不能为负(退款场景除外,所以要按场景配置),时间不能晚于当前时间太多。
- 交叉校验:如果文本里同时出现“共 3 件”和明细列表,就数一下明细条数对不对;如果不一致,把冲突记进
quality_flags,让决策层知道这里有矛盾。
交叉校验这一层,是我认为最值钱的部分。它不需要额外模型调用,纯粹靠逻辑比对,却能抓住相当一部分抽取错误。尤其是票据、订单、清单这类有内部一致性的内容,效果非常明显。
3.3 图像与文档感知:分块、版面还原、别迷信一次识别
图片和 PDF 的处理思路跟纯文本完全不同。我的经验是:先做版面分析,再做内容识别,最后做结构化归档。直接对整张图做识别,遇到多栏排版、表格、页眉页脚混排时,结果会乱成一锅粥。
具体做法上,我会先用轻量的版面检测把页面切成区域(标题、正文、表格、图注、印章),对每个区域单独识别,最后按阅读顺序拼装。表格单独走一套流程:先识别行列结构,再把单元格内容填进去,最后转成结构化数据。这一步多花的时间很值,因为表格一旦按纯文本识别,行列关系就彻底丢了,后面再想还原基本不可能。
文档类输入还有一个坑:页码和页眉会被重复识别,导致同一段话出现好几次。我会在归一化阶段做一次去重,按段落哈希比对,重复的直接丢掉。另外,扫描件里的印章、手写批注经常被误识别成正文,我的处理是把置信度低且位置异常的文本块单独隔离,不并入主内容,但保留在附件里供人工查看。
注意:不要为了让识别“看起来完整”而放宽阈值。宁可返回“这一块没认出来”,也不要塞一段编造的内容进去。感知层说“不知道”是合格表现,说错话才是事故。
3.4 工具返回值的感知:把接口响应也当成外部世界
这一点经常被忽略。智能体调用工具拿回来的 JSON,本质上也是“感知输入”,同样需要归一化和校验。我见过太多情况是:接口返回了 200 但 body 里是错误码,或者字段名变了、类型从字符串变成数字,智能体毫不知情地继续往下走。
我的处理是把工具返回值也送进感知网关,走一套轻量的映射规则:先按 schema 校验,字段缺失或类型不符就标为感知异常;再把关键字段抽成实体,附上来源标记tool_result;最后把原始响应压缩后存档。这样一来,决策层拿到的永远是统一的感知事件格式,不需要为每个工具写一套解析逻辑。
这里还有个小技巧:给每个工具维护一份“契约快照”,记录上次见到的字段结构。如果这次返回的结构跟快照差异超过阈值,就触发告警。这个机制帮我提前发现过好几次上游接口悄悄改字段的情况,比等到用户投诉要早得多。
3.5 置信度和不确定性:让智能体知道自己没看清
置信度这个数值,最怕变成摆设——所有字段都填 0.9,等于没有。我的做法是把置信度分成三档,而不是连续值:确定(原文直接可见,规则或高置信识别得到)、推测(模型推断,原文有间接依据)、未知(缺失或无法判断)。三档足够决策层做分支判断,也更容