尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

无视觉AI对话引导:状态机+LLM实现精细操作指导

无视觉AI对话引导:状态机+LLM实现精细操作指导 一个没有眼睛的AI也就是没有摄像头、没有视觉模型、只能处理文本的大模型应用能不能教用户戴美瞳答案是能但要换一种设计思路。“没有眼睛”意味着它看不到镜片在哪、看不到用户的手和眼睛所有关键信息都必须由用户用文字反馈进来。真正重要的不是让AI“看见”而是设计一个可靠的感知-决策-执行-反馈闭环用状态机控制流程用标准反馈词换取用户的状态描述再用大模型把枯燥的步骤解释成容易执行的自然语言。这个案例很适合做对话式智能体、AI流程编排、LLM应用开发的入门练习。它不涉及复杂的视觉模型却把状态管理、反馈解析、模型调用、异常兜底这些生产级问题都覆盖到了。本文会从零实现一个可运行的最小引导器提供完整的 Python 代码、HTTP 接口、命令行验证方式并给出多轮对话中最常见的5类问题排查思路。学完后你可以把同样的架构迁移到教程引导、设备操作指导、工单分诊等场景。注意美瞳属于角膜接触镜本文只讨论软件工程实现不构成医疗建议。实际佩戴必须以产品说明书和验光师建议为准出现疼痛、红眼、持续异物感时应立即停戴并咨询眼科医生。1. 先理解无视觉AI为什么能指导精细操作1.1 没有眼睛缺的是感知而不是决策大模型擅长的是把一条指令解释成清晰、可执行的步骤但它的短板是没有任何实时感知能力。一个“没有眼睛的AI”不知道用户现在洗没洗手不知道镜片在不在指腹上更不知道用户看到的是碗状还是碟状边缘。在戴美瞳这件事上这些信息恰恰是推进流程的关键。视觉系统正常的人类用户可以一眼确认的事情文本AI完全做不到所以必须换一种获取信息的方式把用户当作传感器。每一个步骤结束时AI都要求用户用一句标准化的反馈词描述当前状态。比如“已洗手”“包装正常”“已取出”“碗状”“已佩戴”“正常”。这些反馈词不是让AI猜用户意思而是让用户替AI完成“感知”这一步。1.2 把“看到”变成“说清楚”形成标准反馈协议用户说“我感觉洗得差不多了”这种自然语言对状态机来说很难解析。但如果约定“请直接回复已洗手”服务端就能用一个非常稳定的匹配规则完成状态推进。这里的核心思想是感知信息由用户提供感知语言由系统定义。AI不负责理解模糊描述只负责解释当前步骤并索取标准反馈。这样设计有很多好处状态推进是确定的不会因为模型输出不同而出现流程分叉。每个步骤都可以写单元测试。排查问题时有明确日志用户在哪个状态回复了什么词命中了哪个选项。后续可以扩展到按钮、语音指令或硬件传感器反馈协议本身不用变。1.3 整体架构拆成三部分避免把流程交给模型这个引导器由三个模块组成模块职责位置状态机维护当前步骤、推进规则、终止条件服务端代码反馈解析器判断用户消息是否命中当前步骤的标准反馈词服务端代码LLM生成器在用户没有命中反馈词时用自然语言解释当前步骤模型接口为什么不直接用一段Prompt让LLM自己判断“下一步是什么”因为LLM会犯两类错误一是把多个步骤一次性输出用户根本记不住二是被用户的一句话带偏自己跳到后面的状态。状态推进一旦交给模型整个流程就失去了可测试性也失去了回滚能力。更合理的分工是状态机是唯一的事实来源反馈解析器负责推进LLM只在“用户没按标准词回复”时负责解释和安抚。这就保证了无论模型说什么当前状态都不会乱跳。2. 环境准备与项目结构先把可运行骨架搭起来2.1 环境要求本项目使用 Python 3.10 以上版本。依赖方面只需要一个 Web 框架、一个 HTTP 客户端和一个环境变量管理工具。依赖用途fastapi提供 HTTP 接口uvicorn启动本地服务httpx调用兼容 OpenAI 协议的模型服务接口python-dotenv读取 .env 配置文件模型服务需要支持 OpenAI 的 Chat Completions 协议也就是暴露一个POST /v1/chat/completions接口。常见方案包括本地部署的 vLLM、Ollama或者团队内部搭建的模型网关。如果你已经有一个可调用的大模型 API把地址和模型名写入环境变量即可。2.2 requirements.txt 与 .env 配置新建项目目录no_eye_guide先创建requirements.txtfastapi0.100.0 uvicorn[standard]0.23.0 httpx0.25.0 python-dotenv1.0.0再创建.env.exampleMODEL_API_BASEhttp://127.0.0.1:9000/v1 MODEL_API_KEYEMPTY MODEL_NAMEqwen2.5-7b-instruct MODEL_TEMPERATURE0.7 MODEL_MAX_TOKENS1024 SESSION_TTL_SECONDS1800说明一下每个配置项MODEL_API_BASE模型服务的根地址必须以/v1结尾。实际模型名和端口取决于你部署的服务。MODEL_API_KEY本地服务通常填EMPTY生产环境必须换成真实的Key。MODEL_TEMPERATURE控制回复随机性引导场景建议 0.5 到 0.8不要太高。MODEL_MAX_TOKENS单次回复最大长度引导类回复不需要太长1024 足够。SESSION_TTL_SECONDS会话超时时间这里先预留后面可以用于 Redis 清理。2.3 目录结构与文件职责整个项目包含以下文件no_eye_guide/ ├── app.py # FastAPI 入口 ├── config.py # 读取环境变量 ├── prompts.py # 系统提示词与用户提示词 ├── state_machine.py # 状态定义与会话状态类 ├── requirements.txt ├── .env.example └── client.py # 命令行交互验证脚本每个文件的职责后面会逐步展开。先记住一条原则状态推进逻辑全部放在state_machine.pyapp.py只负责 HTTP 交互和调模型prompts.py只负责拼Prompt。分层清晰之后后面加日志、加Redis、加测试都会容易很多。3. 实现状态机与反馈协议流程推进必须可控3.1 定义步骤与可选反馈词在state_machine.py中定义完整步骤。每个步骤包含状态名、标题、操作说明以及一个feedback_options字典。用户发送的消息只有命中某个选项的关键词状态机才会推进。from dataclasses import dataclass, field from typing import Optional dataclass class Step: state: str title: str instruction: str feedback_options: dict field(default_factorydict) STEPS [ Step( stateSTART, title准备阶段, instruction先洗手然后用干净纸巾擦干。台面保持光线充足、干燥整洁。完成后来回复一条已洗手, feedback_options{ 已洗手: {next: OPEN_PACKAGE, reply: 收到开始检查包装。, terminal: False}, 已洗好: {next: OPEN_PACKAGE, reply: 收到开始检查包装。, terminal: False}, }, ), Step( stateOPEN_PACKAGE, title检查包装, instruction检查镜片包装是否破损浸泡液是否浑浊。如果一切正常回复包装正常如果发现包装破损、泡液浑浊回复包装异常, feedback_options{ 包装正常: {next: TAKE_OUT_LENS, reply: 包装确认正常进入取出镜片步骤。, terminal: False}, 包装异常: {next: ABORT, reply: 请停止操作更换一副新镜片并保留异常包装信息。, terminal: True}, }, ), Step( stateTAKE_OUT_LENS, title取出镜片, instruction用专用镊子或洗干净的手指轻轻取出镜片放在指腹上。不要用指甲刮擦镜片。完成后回复已取出, feedback_options{ 已取出: {next: CHECK_SIDE, reply: 进入正反面确认环节。, terminal: False}, }, ), Step( stateCHECK_SIDE, title确认正反面, instruction把镜片放在指腹上观察边缘轮廓。边缘像小碗一样自然竖立是正面回复碗状边缘向外摊开像碟子是反面回复碟状。拿不准就多换几个角度观察, feedback_options{ 碗状: {next: WEAR_LENS, reply: 正面已确认准备佩戴。, terminal: False}, 正面: {next: WEAR_LENS, reply: 正面已确认准备佩戴。, terminal: False}, 碟状: {next: CHECK_SIDE, reply: 你看到的是反面。用指腹轻轻把镜片翻成碗状再观察一次确认后回复碗状, terminal: False}, 反面: {next: CHECK_SIDE, reply: 你看到的是反面。用指腹轻轻把镜片翻成碗状再观察一次确认后回复碗状, terminal: False}, }, ), Step( stateWEAR_LENS, title佩戴, instruction一只手食指轻提上眼睑另一只手轻轻拉开下眼睑眼睛向下看把镜片贴在角膜上。完成后回复已佩戴, feedback_options{ 已佩戴: {next: AFTER_CHECK, reply: 进入佩戴后检查。, terminal: False}, }, ), Step( stateAFTER_CHECK, title佩戴后检查, instruction闭眼转动眼球几次然后睁眼感受。没有明显异物感回复正常如果出现持续刺痛、发红、视线模糊回复不舒服, feedback_options{ 正常: {next: DONE, reply: 操作完成。请记录佩戴时间严格按照产品说明控制佩戴时长。, terminal: True}, 不舒服: {next: ABORT, reply: 请立即取下镜片停止佩戴。如果眼睛持续不适请尽快咨询眼科医生。, terminal: True}, }, ), Step( stateDONE, title完成, instruction本流程已结束。, feedback_options{}, ), Step( stateABORT, title已安全终止, instruction本流程已安全中止请根据指引处理。, feedback_options{}, ), ] STEP_MAP {step.state: step for step in STEPS}这段代码有几个关键设计。第一CHECK_SIDE状态的“碟状”分支仍然回到CHECK_SIDE而不是直接让用户佩戴。因为镜片如果是反面直接佩戴会带来明显异物感必须让用户先翻面确认。第二匹配逻辑是“关键词包含”而不是“完全相等”。用户回复“我已洗手”也能命中“已洗手”这降低了用户输入成本后面章节会提到匹配的坑。第三终端状态有两个正常完成是DONE异常终止是ABORT。进入终态后后续任何消息都不会再推进状态。3.2 会话状态类推进和未命中的处理GuideSession负责维护单个用户的状态每次进来先匹配反馈词匹配到就走转移逻辑匹配不到就返回“需要LLM介入”的标记。dataclass class StateResult: reply: Optional[str] terminal: bool extra: dict class GuideSession: def __init__(self, session_id: str): self.session_id session_id self.current_state START self.history [] property def step(self) - Step: return STEP_MAP[self.current_state] def handle(self, user_message: str) - StateResult: self.history.append({role: user, content: user_message}) if self.current_state in (DONE, ABORT): return StateResult( replyself.step.instruction, terminalTrue, extra{need_llm: False}, ) option self._match_option(user_message) if option: transition self.step.feedback_options[option] reply transition[reply] self.current_state transition[next] self.history.append({role: assistant, content: reply}) return StateResult( replyreply, terminaltransition[terminal], extra{need_llm: False, matched_option: option}, ) return StateResult( replyNone, terminalFalse, extra{need_llm: True}, ) def _match_option(self, user_message: str) - Optional[str]: msg user_message.strip() for option_key in self.step.feedback_options: if msg option_key or option_key in msg: return option_key return Nonehandle的返回值中reply为None表示没有命中标准反馈词需要调用方请求LLM解释。extra里附带命中的选项方便日志追踪。这里要明确一个原则LLM永远不会推进状态。即使用户回复了一句话“帮我戴一下”最多只会得到LLM的解释状态仍然停留在当前步骤。这样可以保证流程不会因为模型幻觉而跳步。3.3 为什么反馈解析器不用自然语言理解有人会问既然已经接了LLM为什么不让模型理解“我感觉已经洗好了”这种话主要原因有三个。第一是成本。每个未命中消息都调用一次模型如果状态推进也交给模型那每一步都要调用费用和延迟都会明显上升。而关键词匹配是本地运算毫秒级完成。第二是可测试性。状态推进规则在代码里可以直接写单元测试。比如测试“用户发送已洗手后状态从START变成OPEN_PACKAGE”。这样的测试可以放在CI里模型输出无法做同样的保证。第三是安全。戴美瞳属于医疗相关场景流程不能依赖模型猜测。关键词匹配的结果是确定的用户说包装异常就进入安全终止分支用户说不舒服就建议停戴就医。这些分支必须由代码保证。4. 接入LLM生成解释模型只负责说话不负责决策4.1 配置读取模块在config.py中统一读取环境变量import os from dotenv import load_dotenv load_dotenv() MODEL_API_BASE os.getenv(MODEL_API_BASE, http://127.0.0.1:9000/v1) MODEL_API_KEY os.getenv(MODEL_API_KEY, EMPTY) MODEL_NAME os.getenv(MODEL_NAME, ) MODEL_TEMPERATURE float(os.getenv(MODEL_TEMPERATURE, 0.7)) MODEL_MAX_TOKENS int(os.getenv(MODEL_MAX_TOKENS, 1024))环境变量集中管理的好处是代码里不出现硬编码地址和模型名切换测试模型或生产模型时只需要改.env。4.2 LLM客户端兼容OpenAI协议在app.py内部实现一个简单的LLM客户端。这里直接使用httpx发起请求不依赖官方SDK减少一个安装依赖。import httpx from config import ( MODEL_API_BASE, MODEL_API_KEY, MODEL_NAME, MODEL_TEMPERATURE, MODEL_MAX_TOKENS, ) class LlmClient: def __init__(self): self.api_base MODEL_API_BASE self.api_key MODEL_API_KEY self.model MODEL_NAME async def generate(self, messages): url f{self.api_base}/chat/completions payload { model: self.model, messages: messages, temperature: MODEL_TEMPERATURE, max_tokens: MODEL_MAX_TOKENS, top_p: 0.9, } headers {Authorization: fBearer {self.api_key}} async with httpx.AsyncClient(timeout30) as client: resp await client.post(url, jsonpayload, headersheaders) resp.raise_for_status() data resp.json() return data[choices][0][message][content]注意几点超时时间设了30秒。模型服务在冷启动或负载高时可能很慢30秒是引导场景能接受的等待上限。model从环境变量读取如果为空模型服务会返回400这时要在日志里明确提示。top_p固定为0.9和temperature配合控制随机性不单独暴露配置项。4.3 系统提示词约束模型的安全边界在prompts.py中编写系统提示词。这个提示词是安全边界的关键必须做到无视觉、不推进状态、不诊断、不推荐品牌。SYSTEM_PROMPT 你是一个没有视觉能力的 AI 助手专门通过文字引导用户完成美瞳佩戴。 你看不到摄像头画面看不到用户的手和眼睛唯一能依靠的是用户用文字反馈的信息。 你的职责是解释当前步骤、安抚用户情绪、帮助用户理解标准反馈词但绝不能自行推进流程。 规则 1. 状态推进由服务端代码决定你只负责回复文字内容。 2. 每次解释只围绕当前步骤不要提前给出后续步骤的完整操作。 3. 当用户没有按标准反馈词回复时重新解释当前步骤并明确告诉用户应该回复哪个关键词。 4. 不要假设用户已完成某个动作必须以用户明确反馈为准。 5. 你只能提供通用操作引导。如果用户提到疼痛、视力下降、持续红眼、严重不适请建议停止佩戴并咨询眼科医生。 6. 不要推荐任何品牌不要做医疗诊断不要建议超长佩戴。这里最容易被忽略的是第2条。如果不加限制模型很可能在用户还没走到“取出镜片”时就把“佩戴”的完整操作一起说出来。用户记不住流程也失去意义。4.4 用户提示词只给当前步骤必要信息build_user_prompt负责把当前步骤的信息拼接成用户消息。这里不传完整对话历史只传当前步骤和最近的几条记录避免Prompt过长。def build_user_prompt(step, user_message, history): option_text 、.join(step.feedback_options.keys()) recent history[-4:] if history else [] history_text \n.join( f{item[role]}: {item[content]} for item in recent ) return f当前步骤【{step.title}】 步骤说明{step.instruction} 用户必须回复的标准关键词{option_text} 用户刚刚输入{user_message} 最近对话 {history_text} 请用简洁中文回复用户。如果用户输入不是标准关键词向用户解释当前步骤并引导他回复标准关键词。为什么只传最近4条历史因为状态机本身就是状态的持久化载体历史只服务于措辞理解不需要包含完整上下文。这个设计可以大大减少Token消耗也让每次模型调用变成相对“无状态”的计算排查时更容易复现。5. 提供HTTP接口并完成运行验证5.1 FastAPI接口实现把状态机和LLM客户端组装到app.py中。from fastapi import FastAPI from pydantic import BaseModel from prompts import SYSTEM_PROMPT, build_user_prompt from state_machine import GuideSession app FastAPI() sessions {} llm LlmClient() class GuideRequest(BaseModel): session_id: str message: str class GuideResponse(BaseModel): session_id: str current_state: str state_title: str message: str terminal: bool progressed: bool extra: dict {} app.post(/api/v1/guide) async def guide(req: GuideRequest): session sessions.get(req.session_id) if session is None: session GuideSession(req.session_id) sessions[req.session_id] session result session.handle(req.message) if result.extra.get(need_llm): messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: build_user_prompt(session.step, req.message, session.history)}, ] reply await llm.generate(messages) return GuideResponse( session_idreq.session_id, current_statesession.current_state, state_titlesession.step.title, messagereply, terminalFalse, progressedFalse, extra{matched_option: None}, ) return GuideResponse( session_idreq.session_id, current_statesession.current_state, state_titlesession.step.title, messageresult.reply, terminalresult.terminal, progressedresult.reply is not None, extraresult.extra, )这里直接使用了内存字典sessions适合本地验证不适合生产。生产环境至少需要做两件事一是用 Redis 替换内存字典二是给GuideSession加会话锁避免同一个session_id并发请求导致状态错乱。5.2 启动服务安装依赖并启动pip install -r requirements.txt cp .env.example .env uvicorn app:app --host 0.0.0.0 --port 8080如果模型服务跑在9000端口就不冲突。如果模型服务也在8080可以把这里的端口改成8081。启动后能看到 FastAPI 默认日志。接着需要确认模型服务是否可用最简单的方式是直接访问模型服务的健康检查接口或者用下面的 Python 脚本做一次通话测试。5.3 用命令行脚本模拟完整对话链创建client.py进行交互验证import uuid import httpx BASE http://127.0.0.1:8080 session_id str(uuid.uuid4()) print(无视觉AI美瞳引导器输入反馈词开始。) while True: msg input( ).strip() if not msg: continue resp httpx.post( f{BASE}/api/v1/guide, json{session_id: session_id, message: msg}, timeout40, ) data resp.json() print(f[{data[current_state]}] {data[message]}) if data[terminal]: print(流程已结束。) break重点看progressed字段true表示这次消息让状态发生了推进false表示只是模型解释状态没动。如果用户连续输入标准反馈词最终会走到terminal: true。5.4 正常流程与偏离流程的预期表现正常流程的对话大致如下 已洗手 [OPEN_PACKAGE] 收到开始检查包装。 包装正常 [TAKE_OUT_LENS] 包装确认正常进入取出镜片步骤。 已取出 [CHECK_SIDE] 进入正反面确认环节。 碗状 [WEAR_LENS] 正面已确认准备佩戴。 已佩戴 [AFTER_CHECK] 进入佩戴后检查。 正常 [DONE] 操作完成。请记录佩戴时间严格按照产品说明控制佩戴时长。 流程已结束。如果用户没有按标准反馈词回复比如输入“我有点慌”状态机会返回need_llm调用模型生成解释但状态仍然停留在当前步骤 我有点慌 [CHECK_SIDE] 不用紧张你只需要把镜片放在指腹上看它的边缘轮廓。像小碗一样竖立是正面回复“碗状”向外摊开是反面回复“碟状”。请确认后回复碗状再用 HTTP 工具验证状态没有推进curl -X POST http://127.0.0.1:8080/api/v1/guide \ -H Content-Type: application/json \ -d {session_id:demo-001,message:我有点慌}响应中的current_state仍然是CHECK_SIDEprogressed为false。这个结果说明模型的解释和流程推进完全解耦模型乱说不会带偏状态机。5.5 验证要点总结下面是一张验证表用来判断服务是否正常验证项输入预期结果状态推进已洗手state 变为 OPEN_PACKAGEprogressed 为 true命中标准词包装正常state 变为 TAKE_OUT_LENS未命中反馈词随便聊天state 不变progressed 为 false反面纠正碟状state 保持 CHECK_SIDE要求翻面安全终止不舒服state 变为 ABORTterminal 为 true终态保护终态后任意输入不再推进返回结束文案这些用例建议写成 pytest 测试纳入回归。状态机是流程系统中最不该出错的部分测试价值很高。6. 常见问题与排查链路多轮引导最容易踩的5个坑6.1 标准反馈词明明正确状态却不变现象用户输入“已洗手”返回的progressed是false状态停在同一位置。排查步骤确认请求里的session_id是否一致。前端切换会话后新开状态服务端查不到历史状态就会从START重新开始。确认当前状态是不是DONE或ABORT这两个状态下反馈词不会再匹配。确认用户输入里是否包含多余前缀例如“好的我已洗手”。按现在的包含逻辑“我已洗手”仍会命中但输入“好的我已经洗完了”不会命中。在日志里检查matched_option字段如果为空说明根本没有命中选项。解决方式把同义词膨胀成一个ALIAS_MAP在_match_option里先查别名再做包含匹配。不要在代码里允许“洗完了”这种模糊词否则规则会越来越复杂。6.2 LLM一次性输出多个步骤现象用户没有按反馈词回复模型在解释时把“取出镜片、确认正反面、佩戴”全写了出来用户直接蒙了。原因系统提示词约束不够强模型默认选择“完整回答”而不是“当前一步”。解决方式在系统提示词中增加更严格的限制并在请求参数中把max_tokens调低到400到512。如果还不行可以关闭复杂解释只返回预置的step.instruction让LLM只负责回答“为什么”类问题不负责重复操作步骤。6.3 关键词包含匹配误触发现象用户说“包装正常吗我觉得不太正常”包含了“包装正常”会被误判为正常并推进状态。原因包含匹配只判断子串不看否定语义。解决方式先做精确匹配精确匹配失败时再做包含匹配。对安全问题相关的步骤比如“包装异常”和“不舒服”要单独提升匹配优先级并增加必选反馈词列表。生产场景更稳妥的方式是使用小型文本分类模型或规则引擎而不是裸字符串匹配。问题现象可能原因检查方式处理建议状态不推进session_id 不一致或终态保护查看请求与日志确认会话唯一标识回复过长提示词约束不足查看模型
返回列表