从 RAG 到 Agent:社保智能客服的进化(下)——多模态与完整链路


[上篇] 我们搭好了 Agent 的"脑子"——Function Calling 意图识别 + 七态状态机 + Session 管理 + Config 驱动。这篇看 Agent 怎么把"手"和"脚"伸出去——OCR 拍照识别、人脸核验、语音交互,然后跟着一个完整案例从头走到尾。


一、两种业务流程:fields vs steps

上篇提过,Agent 支持两种流程分支:

字段流(fields):逐字段文字收集。适合纯信息类业务——查养老金要身份证号,社保转移要四个字段,参保登记要姓名+身份证+手机号。

步骤流(steps):多步操作,每一步可能是拍照、人脸扫描。适合需要"真东西"的业务——养老金资格认证要传身份证照片、要做人脸活体检测。

# 字段流:社保关系转移
- id: "social_transfer"
  type: "handle"
  fields:
    - key: "from_city"
      label: "转出城市"
      type: "city_select"
    - key: "to_city"
      label: "转入城市"
    - key: "id_number"
      label: "身份证号"
      type: "idcard"
    - key: "name"
      label: "姓名"
      type: "text"
  require_confirm: true

# 步骤流:养老金资格认证
- id: "pension_auth"
  type: "handle"
  steps:
    - step: "idcard_photo"    # 第一步:拍身份证
      type: "photo"
      label: "身份证正面照片"
      prompt: "请上传您的身份证正面照片"
      field_maps:             # OCR结果自动填入字段
        name: "name"
        id_number: "id_number"
    - step: "face_scan"       # 第二步:人脸核验
      type: "face_scan"
      label: "人脸识别认证"
      require_prev_photo: "idcard_photo"  # 引用上一步的身份证照片
  require_confirm: true

两种流互不冲突,同一个 FlowController 根据 business.steps 是否存在自动选择分支。


二、身份证 OCR:PaddleOCR 解析证件

步骤流第一步通常是身份证拍照。后端收到图片后:

def process_idcard_photo(self, image_bytes: bytes, user_id: str) -> dict:
    # 1. 上传 MinIO 留存
    object_name = self._storage.upload_bytes(image_bytes, "jpg", 
                                              sub_dir=f"idcard/{user_id}/")
    
    # 2. 写入临时文件,调 PaddleOCR
    with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
        tmp.write(image_bytes)
        tmp_path = tmp.name
    
    result = self._ocr.ocr(tmp_path, cls=True)
    text_lines = self._extract_text(result)
    
    # 3. 正则解析:姓名、身份证号、地址
    parsed = self._parse_idcard(text_lines)
    
    return {
        "file_url": self._storage.get_url(object_name),
        "object_name": object_name,
        "name": parsed.get("name", ""),
        "id_number": parsed.get("id_number", ""),
        "address": parsed.get("address", ""),
        "valid": bool(parsed.get("id_number") and parsed.get("name")),
    }

解析逻辑不是 OCR 模型自带的,是正则提取:

@staticmethod
def _parse_idcard(text_lines):
    result = {"name": "", "id_number": "", "address": ""}
    full_text = "\n".join(text_lines)
    
    # 身份证号:18位数字/X
    id_match = re.search(r"([1-9]\d{16}[\dXx])", full_text)
    if id_match:
        result["id_number"] = id_match.group(1)
    
    # 姓名:先匹配"姓名:"标签,找不到则取2-4个连续中文
    name_match = re.search(r"姓名\s*[::]\s*(.+)", full_text)
    if name_match:
        result["name"] = name_match.group(1).strip()
    else:
        for line in text_lines:
            if 2 <= len(line) <= 4 and all("\u4e00" <= c <= "\u9fff" for c in line):
                result["name"] = line
                break
    
    return result

OCR 结果不会 100% 准确。所以 valid 字段交给调用方判断——姓名和身份证号都识别到了才算通过,否则提示用户重新拍摄。

识别成功后,field_maps 把 OCR 结果自动填入 collected 字段:

field_maps:
  name: "name"           # OCR的name → 收集字段的name
  id_number: "id_number" # OCR的id_number → 收集字段的id_number

用户不用手打姓名和身份证号,拍照一次就够了。


三、人脸核验:活体检测 + 1:1 比对

认证类业务(养老金资格认证)的第二步是人脸核验。配置里用 require_prev_photo 关联上一步的身份证照片:

- step: "face_scan"
  type: "face_scan"
  label: "人脸识别认证"
  prompt: "请将面部对准取景框,按提示完成眨眼/张嘴/摇头动作"
  require_prev_photo: "idcard_photo"  # 用身份证照片做比对基准

FlowController.handle_face() 拿到用户现场拍摄的人脸图,加上 session 里存的上一步身份证照片的 MinIO 路径:

def handle_face(self, user_id, face_image_bytes):
    session = self._store.get(user_id)
    current_step = session.get("current_step", {})
    
    # 从 session 获取上一步的身份证照片
    prev_step_name = current_step.get("require_prev_photo")
    prev_result = session["step_results"].get(prev_step_name, {})
    idcard_obj = prev_result.get("object_name")  # MinIO 文件路径
    
    # 调人脸服务:活体检测 + 人脸比对
    ok, msg = handler.verify(face_image_bytes, idcard_obj)
    
    if not ok:
        return {"content": f"{msg}\n\n请重新进行人脸扫描。"}
    
    # 通过后记录结果,推进到确认或完结
    step_results[current_step["step"]] = {"status": "passed"}
    return self._finalize(user_id, business, session)

当前版本人脸服务的活体检测和比对接口是(stub),返回固定成功结果。真实对接在规划中。

核验通过后,face_verified 标志写入 collected,证明"这个人拍了身份证、又通过了人脸比对"——业务接口收到这个字段就知道用户完成了实名认证。


四、完整案例:社保关系转移,从头走到尾

社保关系转移 跑一遍完整链路。

Step 1:用户表达意图

用户: 我要把社保从杭州转到北京

Agent 处于 idle 状态。IntentEngine 把用户输入 + 业务列表送入 LLM。LLM 调用 match_business

{
  "intent": "match_business",
  "arguments": {
    "business_id": "social_transfer",
    "reason": "用户明确表达了跨城市社保转移的意图,提到了转出地杭州和转入地北京"
  }
}

FlowController 从 config 读取 social_transfer 的定义——4 个字段、需要确认、类型为 handle。进入 collecting 状态,返回第一条提示:

好的,您要办理的是【社保关系转移】,请提供以下信息:

(1/4) 请输入您原来参保的城市

Step 2:逐字段收集

用户: 杭州

LLM 在 collecting 上下文中,当前字段是 from_city,用户输入"杭州"——provide_field(field_key="from_city", field_value="杭州")。校验通过(中文城市名),存入 collected。推进到下一字段:

(2/4) 请输入您要转入的城市
用户: 北京

→ 校验通过 → 推进。

(3/4) 请输入您的身份证号
用户: 320102199001011234

→ ID card 校验器检查格式(18 位数字/X)+ 校验位计算,通过 → 推进。

(4/4) 请输入您的姓名
用户: 张三

→ 校验通过,全部字段收齐。

Step 3:确认提交

FlowController 检测到 next_idx >= len(fields),调用 _finalize()social_transfer 配置了 require_confirm: true,进入 confirming 状态:

请确认以上信息无误后,点击确认提交社保关系转移申请。

汇总信息:
- 社保关系转移
  - 转出城市: 杭州
  - 转入城市: 北京
  - 身份证号: 32****34
  - 姓名: 张三

回复"确认"提交,回复"取消"退出。

注意身份证号做了脱敏显示(32****34),前端不会暴露完整证件号。但 collected 里存的是原始值,调接口时用。

用户: 确认

LLM 在 confirming 上下文中识别为 confirm_submit。FlowController 进入 executing 状态,调用 ApiCaller

def _execute_api(self, user_id, business, session):
    collected = session["collected"]
    step_results = session.get("step_results", {})
    
    # 合并字段收集结果和步骤结果
    merged = dict(collected)
    for step_name, step_result in step_results.items():
        for step_def in business.get("steps", []):
            if step_def["step"] == step_name:
                for src_key, dest_key in step_def.get("field_maps", {}).items():
                    merged[dest_key] = step_result.get(src_key, merged.get(dest_key, ""))
    
    # 调业务接口
    api_result = self._api.call(business, merged)
    return self._api.format_result(business, api_result)

当前 ApiCaller 为 Mock 实现,返回预设的 JSON。真实业务接口通过 config 中的 ${变量} 配置,对接后直接生效。

返回结果:

办理成功!您的社保关系转移申请已提交。
受理编号:TRF-2025-05007-001
预计处理时间:15 个工作日

请问还有什么可以帮您的?

状态回到 done。Session 保留 30 分钟,用户随时可以回来继续。


五、输入校验:不止是正则

FieldValidator 用注册模式支持多种字段类型:

@FieldValidator.register("idcard")
def validate_idcard(value, field_def):
    if not re.match(r"^\d{17}[\dXx]$", value):
        return False, "格式不正确,应为18位"
    # 校验位计算
    weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
    check_codes = "10X98765432"
    total = sum(int(value[i]) * weights[i] for i in range(17))
    expected = check_codes[total % 11]
    if value[17].upper() != expected:
        return False, "校验位不正确,请检查"
    return True, ""

@FieldValidator.register("phone")
def validate_phone(value, field_def):
    if not re.match(r"^1[3-9]\d{9}$", value):
        return False, "格式不正确,应为11位手机号"
    return True, ""

内置类型包括 idcard(含校验位)、phoneyear(2000-当前年份范围)、enum(选项匹配,支持数字和文本)、city_select(正则匹配中文城市名)。新增校验器只需加一个 @FieldValidator.register("xxx") 装饰器。

校验失败时返回的 error_msg 来自 config 中的字段定义,不用硬编码在代码里:

- key: "id_number"
  type: "idcard"
  validate: "^\\d{17}[\\dXx]$"
  error_msg: "身份证号格式不正确,应为18位"

六、多模态:不止打字

上篇和下篇都在讲文本交互,但 Agent 的输入通道远不止于此。前端集成了讯飞语音识别(IAT)语音合成(TTS),用户可以直接语音提问,回答自动播报。拍照和人脸采集通过浏览器直接调摄像头,Base64 编码后走 /agent/photo/upload/agent/face/verify 两个接口。

这些不是 Agent 的核心创新,但它让 Agent 从"开发者的玩具"变成了"群众能用的东西"——很多办事的人不会打字、不想打字、或者手边没键盘。语音 + 拍照 + 人脸,三个通道覆盖了真实的交互场景。


七、路由设计一览

Flask 服务一共 10 个路由,分三组:

RAG 通道(兼容上一篇)

路由 方法 说明
/getAnser GET RAG 同步问答
/getAnserStream GET RAG 流式问答
/AI GET 前端页面

Agent 通道

路由 方法 说明
/agent/chat POST 文本对话(非流式)
/agent/chat/stream POST 文本对话(模拟流式)
/agent/session GET 查询当前会话状态
/agent/session DELETE 重置会话
/agent/photo/upload POST 上传身份证照片
/agent/face/verify POST 人脸核验

认证

路由 方法 说明
/auth/login POST JWT 登录,返回 token

Agent 通道的接口都带 @auth.require_auth 装饰器,通过 Bearer token 鉴权。auth.enabled: false 可关闭。


八、诚实交代

这篇博客写到的东西,不是全部都已经对接了生产系统。实话:

  1. 业务 API(ApiCaller:当前是 Mock 实现,返回硬编码的 JSON 响应。真实业务接口在对接中,架构层面已预留了 config.yaml 中的 ${VARIABLE} 环境变量 + field_mapping 字段映射,对接时改配置即可。

  2. 人脸服务(FaceHandler:活体检测和 1:1 比对接口是桩,返回固定成功。人脸算法本身不是这个项目自研的,需要对接第三方服务。

  3. 前端:当前是原生 HTML/CSS/JS,语音用了讯飞 SDK,整体可用但不算精致。后续考虑迁移到 Vue/React。

这些不是藏着掖着的缺陷,是分步落地的正常节奏——框架先跑通,接口一个一个接。博客的价值在于把架构讲清楚,读者拿去可以改、可以接自己的东西。


九、总结:RAG 到 Agent,变了什么

回看两篇的完整进化线:

维度 RAG(上一篇) Agent(上+下篇)
交互模式 一问一答 多轮对话 + 流程引导
意图识别 无,全走检索 Function Calling,根据状态切换工具集
状态管理 无状态 七态状态机 + Redis 持久化
业务处理 只答不问 收集字段 → 确认 → 调接口
输入通道 文本 文本 + 语音 + 拍照 + 人脸
扩展方式 改代码 改 YAML 配置
主动性 被动回答 主动推荐业务办理

这套 Agent 框架的内核是可复用的。Config 里的业务类型换成政务服务、企业审批、银行开户,骨架不变。如果你也在从 RAG 往 Agent 走,希望这两篇能省你一点摸索的时间。


相关链接

  • 上篇:从 RAG 到 Agent(上)——意图识别与状态机
  • DeepSeek Function Calling:https://api-docs.deepseek.com/guides/function_calling
  • PaddleOCR:https://github.com/PaddlePaddle/PaddleOCR
  • 项目源码:https://github.com/xxx/si_agent
Logo

更多推荐