1. 做饭时手上沾满油,为什么还要跟 AI 较劲
场景很具体:锅里油温七成,手上全是油,手机在客厅充电,平板挂在墙上但不想用油手去点屏幕。这时候你只想问一句“糖醋排骨先放糖还是先放醋”,然后有人回答你,不用碰任何设备。
这就是具身交互要解决的问题。纯文本 Agent 在厨房场景几乎无法落地——颠勺做菜的时候,根本腾不出双手敲键盘、触摸屏。Agent 的认知能力(理解、推理、记忆、工具调用)已经足够成熟,但输出如果只能通过文字,它就永远被困在“需要你主动去用”的工具定位里。
我这次要做的,是把数字人厨师长部署在厨房壁挂屏上:直接语音提问,即可解答烹饪步骤、支持多轮追问、烹饪定时提醒,还可以结合冰箱现有食材自动推荐菜谱。整套链路用 Vue + SDK 搭建,通过 TaoToken 统一 Key/API 通道接入 Agent 语音问答,目标让开发者快速跑通油手免触交互 Demo。
适合谁看:做过 Vue 项目、想给 Agent 加一层“身体”的前端或全栈开发者;正在做智能硬件/厨房终端/展厅交互的产品同学;以及被“聊天框 Agent”困住、想找具身交互落地场景的人。
下面按“原问题 → TaoToken 前置 → 可复制配置 → 验证请求 → 常见错排查 → CTA”的顺序拆,每一步都能直接抄。
2. TaoToken 前置:一把 Key 打通语音问答链路
数字人厨师长的核心链路是:语音输入 → RAG 检索 + 大模型推理 → 流式文本 → 数字人 SDK 参数流驱动 → 数字人说话。其中“大模型推理”这一环,我选择用 TaoToken 统一 Key 接入。
为什么不用各家模型分别申请 Key?因为厨房助手这个场景会频繁切换模型:菜谱推理用 Qwen Plus,意图识别用轻量模型,后续可能还要接 Claude 做复杂菜谱规划。如果每个模型一套 Key、一套计费、一套 SDK,维护成本会爆炸。TaoToken 提供统一 Key/API 通道,一个 Key 走 OpenAI 兼容协议,切换模型只改model字段。
TaoToken 是什么:一个统一的大模型 API 接入通道,兼容 OpenAI 协议,支持多模型路由。能做什么:用一个 Key 调用多家模型,适合 Agent、Coding、语音问答等需要多模型协作的场景。适合谁:不想在多个模型平台之间反复注册、充值、维护 Key 的开发者。
接入前你需要准备:
- 一个 TaoToken 账号,在控制台创建 API Key
- 一个数字人 SDK 的 App ID / App Secret(本文用通用 SDK 占位,实际替换为你选用的数字人服务)
- 一个 Vue3 + TypeScript 项目
TaoToken 的 API 地址是https://taotoken.net/api,兼容 OpenAI 的/v1/chat/completions路径。也就是说,你原来用 OpenAI SDK 写的代码,只需要改baseURL和apiKey两个字段,就能切到 TaoToken。
注意:API 地址不要加 UTM 参数,直接写
https://taotoken.net/api即可。控制台和 API Keys 页面在 deep link 里,后面 CTA 会给。
3. 可复制配置:settings.json 骨架与 Vue 服务层
3.1 settings.json 配置骨架
先给一份可直接复制的settings.json,放在项目根目录或src/config/下。这份配置把 TaoToken 的 Key、模型、数字人 SDK 参数、语音链路参数全部集中管理,避免散落在代码里。
{ "taotoken": { "baseURL": "https://taotoken.net/api", "apiKey": "sk-your-taotoken-key", "defaultModel": "qwen-plus", "fallbackModel": "qwen-turbo", "timeout": 30000, "maxRetries": 2 }, "avatar": { "appId": "your_avatar_appid", "appSecret": "your_avatar_appsecret", "containerId": "avatar-sdk", "gatewayServer": "https://your-avatar-gateway/session", "hardwareAcceleration": "prefer-hardware" }, "voice": { "wakeWord": "大厨", "sampleRate": 16000, "chunkSize": 6, "interruptEnabled": true }, "rag": { "topK": 3, "maxContextLength": 2000 } }字段说明用表格对照更清楚:
| 字段 | 作用 | 建议值 |
|---|---|---|
taotoken.baseURL | TaoToken API 入口 | https://taotoken.net/api |
taotoken.defaultModel | 菜谱推理主模型 | qwen-plus |
taotoken.fallbackModel | 主模型超时降级 | qwen-turbo |
voice.chunkSize | 流式文本攒够几个 chunk 再驱动数字人 | 6 |
voice.interruptEnabled | 是否允许语音打断 | true |
rag.topK | 知识库检索返回条数 | 3 |
3.2 Vue 服务层:llm.service.ts
在src/services/下新建llm.service.ts,负责调用 TaoToken。注意这里用的是 OpenAI 兼容协议,baseURL指向 TaoToken。
// src/services/llm.service.ts import settings from '../config/settings.json'; const SYSTEM_PROMPT = `你是厨房AI助手"大厨",帮用户解决烹饪问题。 你的特点: 1. 回答简洁,2-3句话,因为用户在做菜,手忙脚乱没空听长篇大论 2. 步骤清晰,用"先...再...最后..."的句式 3. 记住用户的口味偏好(通过RAG注入),推荐菜谱时自动规避忌口 4. 涉及油温、火候给具体描述,比如"筷子放进去周围冒密集小泡就是七成热" 5. 用户问到做不出来的菜时,给出替代方案 6. 语气热情,像厨房里的大厨在教你做菜`; export async function streamChat( userMessage: string, onChunk: (text: string) => void, context: Array<{ role: string; content: string }> = [] ) { const messages = [ { role: 'system', content: SYSTEM_PROMPT }, ...context.slice(-6), { role: 'user', content: userMessage }, ]; const response = await fetch(`${settings.taotoken.baseURL}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${settings.taotoken.apiKey}`, }, body: JSON.stringify({ model: settings.taotoken.defaultModel, messages, stream: true, }), }); if (!response.ok) { throw new Error(`TaoToken 请求失败: ${response.status}`); } const reader = response.body!.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (!line.startsWith('data: ') || line === 'data: [DONE]') continue; try { const json = JSON.parse(line.slice(6)); const content = json.choices?.[0]?.delta?.content; if (content) onChunk(content); } catch { // 忽略解析异常 } } } }3.3 RAG 服务层:rag.service.ts
光有记忆不够,Agent 回答烹饪问题时得能从知识库里检索菜谱信息,注入到大模型上下文里,避免编菜谱。
// src/services/rag.service.ts import { search as searchKB } from './knowledge-base.service'; import { streamChat } from './llm.service'; export async function ragQuery( question: string, onChunk: (text: string) => void ) { // 1. 从知识库检索相关信息 const relevant = searchKB(question); const context = relevant .map((e) => `[${e.type}] ${e.content}`) .join('\n'); // 2. 构造增强 prompt let enhancedPrompt = question; if (context) { enhancedPrompt = `参考以下已知信息回答用户问题。 已知信息: ${context} 用户问题:${question}`; } // 3. 流式调用大模型 await streamChat(enhancedPrompt, onChunk); }3.4 知识库服务层:knowledge-base.service.ts
厨房助手需要记住三类信息:用户的口味偏好、当前菜谱的进度、常备食材清单。
// src/services/knowledge-base.service.ts const STORAGE_KEY = 'kitchen_assistant_kb'; interface KB { preferences: Array<{ id: number; content: string; createdAt: string }>; ingredients: string[]; progress: { dish?: string; step?: number; updatedAt?: string }; } function loadKB(): KB { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return { preferences: [], ingredients: [], progress: {} }; try { return JSON.parse(raw); } catch { return { preferences: [], ingredients: [], progress: {} }; } } function saveKB(kb: KB) { localStorage.setItem(STORAGE_KEY, JSON.stringify(kb)); } export function savePreference(preference: string) { const kb = loadKB(); kb.preferences.push({ id: Date.now(), content: preference, createdAt: new Date().toISOString(), }); saveKB(kb); } export function updateIngredients(ingredients: string[]) { const kb = loadKB(); kb.ingredients = ingredients; saveKB(kb); } export function saveProgress(dish: string, step: number) { const kb = loadKB(); kb.progress = { dish, step, updatedAt: new Date().toISOString() }; saveKB(kb); } export function search(query: string) { const kb = loadKB(); const results: Array<{ content: string; type: string }> = []; kb.preferences.forEach((p) => { if (query.includes(p.content.slice(0, 2))) { results.push({ ...p, type: 'preference' }); } }); if (kb.progress.dish && query.includes(kb.progress.dish)) { results.push({ content: `当前在做${kb.progress.dish},第${kb.progress.step}步`, type: 'progress', }); } if (query.includes('食材') || query.includes('冰箱')) { results.push({ content: `冰箱里有:${kb.ingredients.join('、')}`, type: 'ingredients', }); } return results; } export function getKB() { return loadKB(); }3.5 数字人服务层:avatar.service.ts
这一层负责数字人渲染、语音合成和动作驱动。不同 SDK 的 API 名称不同,这里用通用占位,你替换成实际 SDK 的方法名即可。
// src/services/avatar.service.ts import settings from '../config/settings.json'; let sdkInstance: any = null; export function initAvatar() { sdkInstance = new (window as any).AvatarSDK({ containerId: settings.avatar.containerId, appId: settings.avatar.appId, appSecret: settings.avatar.appSecret, gatewayServer: settings.avatar.gatewayServer, hardwareAcceleration: settings.avatar.hardwareAcceleration, onMessage: (msg: any) => console.log('[Avatar]', msg), }); sdkInstance.init({ onDownloadProgress: (p: number) => console.log(`[Avatar] 资源加载: ${p}%`), }); return sdkInstance; } // 流式驱动:is_start / is_end 控制播报节奏 export function speak(text: string, isStart: boolean, isEnd: boolean) { if (!sdkInstance) return; sdkInstance.speak(text, isStart, isEnd); } // 打断当前播报,切回倾听状态 export function interruptIdle() { if (!sdkInstance) return; sdkInstance.interactiveidle(); } // SSML 驱动:说话 + 语义化动作 export function speakWithAction(text: string, action: string) { const ssml = ` <speak> <ue4event> <type>ka</type> <data><action_semantic>${action}</action_semantic></data> </ue4event> ${text} </speak>`; speak(ssml, true, true); } export function destroyAvatar() { if (sdkInstance) sdkInstance.destroy(); }两个坑先提醒:SDK 只支持localhost或https访问,直接用 IP 会报VideoDecoder is not defined;频繁刷新会触发房间限流,记得在beforeunload调destroyAvatar()释放连接。
4. 验证请求:从语音输入到数字人开口
4.1 主组件 KitchenAssistant.vue
把四个 service 的输出缝到数字人表达上。核心链路:语音输入 → RAG 检索 + 大模型推理 → 流式文本 → 数字人 SDK 参数流驱动 → 数字人说话。
<!-- src/components/KitchenAssistant.vue --> <template> <div class="kitchen-container"> <div id="avatar-sdk"></div> <div class="status-bar"> <span v-if="cooking">正在做:{{ currentDish }} · 第{{ currentStep }}步</span> <span v-else class="idle-hint">说"大厨"唤醒我</span> </div> </div> </template> <script setup lang="ts"> import { ref, onMounted, onBeforeUnmount } from 'vue'; import { initAvatar, speak, interruptIdle, speakWithAction, destroyAvatar, } from '../services/avatar.service'; import { ragQuery } from '../services/rag.service'; import { savePreference, saveProgress, getKB, } from '../services/knowledge-base.service'; import settings from '../config/settings.json'; const cooking = ref(false); const currentDish = ref(''); const currentStep = ref(0); let pendingText: string | null = null; function handleVoiceState(res: any) { const state = typeof res === 'string' ? res : res.state || res.data; if ((state === 'end' || state === 'idle') && pendingText) { speak(pendingText, true, true); pendingText = null; } } function interruptAndSpeak(text: string) { pendingText = text; interruptIdle(); } async function processCommand(msg: string) { // 记忆指令 if (msg.startsWith('记住') || msg.startsWith('我不吃')) { const pref = msg.replace(/^(记住|我不吃)/, '').trim(); savePreference(pref || msg); speakWithAction(`记下了,以后做菜不放${pref || msg}。`, 'ThumbsUp'); return; } // 进度指令 if (msg.includes('记住进度')) { saveProgress(currentDish.value, currentStep.value); speakWithAction('进度已记住,随时可以问我在做到哪一步。', 'ThumbsUp'); return; } // RAG 检索 + 大模型推理 → 流式驱动 let buf = ''; let count = 0; await ragQuery(msg, (chunk) => { buf += chunk; count++; if (count >= settings.voice.chunkSize) { speak(buf, buf === chunk, false); buf = ''; count = 0; } }); if (buf) speak(buf, false, true); } onMounted(() => { initAvatar(); const hour = new Date().getHours(); let greeting = '我准备好啦,今天想做什么菜?'; if (hour < 10) greeting = '早上好,想做点早餐吃吗?煎蛋还是下面条?'; else if (hour > 16) greeting = '该准备晚饭啦,有什么想吃的吗?'; const kb = getKB(); if (kb.preferences.length > 0) { greeting += `我记着你吃${kb.preferences[0].content}呢。`; } speakWithAction(greeting, 'Hello'); }); onBeforeUnmount(() => { destroyAvatar(); }); </script> <style scoped> .kitchen-container { width: 100%; height: 100%; position: relative; } #avatar-sdk { width: 100%; height: 100%; } .status-bar { position: absolute; top: 12px; left: 12px; background: rgba(255, 255, 255, 0.9); padding: 4px 12px; border-radius: 12px; font-size: 13px; color: #333; } .idle-hint { color: #999; } </style>4.2 用 curl 验证 TaoToken 通道
在跑 Vue 之前,先用 curl 确认 TaoToken 通道是通的。这一步能帮你排除 90% 的“Key 没配好”问题。
curl -X POST https://taotoken.net/api/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk-your-taotoken-key" \ -d '{ "model": "qwen-plus", "messages": [ {"role": "system", "content": "你是厨房助手,回答简洁。"}, {"role": "user", "content": "糖醋排骨先放糖还是先放醋?"} ], "stream": false }'成功结果长这样:
{ "id": "chatcmpl-xxx", "object": "chat.completion", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "先放糖。糖在油里炒出焦糖色,再放醋,酸甜味才挂得住排骨。顺序反了醋会挥发,只剩酸味。" }, "finish_reason": "stop" } ] }看到choices[0].message.content有内容,说明 TaoToken 通道正常。如果返回 401,检查 Key;返回 404,检查baseURL是不是写成了https://taotoken.net/api/v1(正确写法是https://taotoken.net/api,路径里再拼/v1/chat/completions)。
4.3 语音链路验证动作
启动 Vue 项目后,按这个顺序验证:
- 打开
http://localhost:5173,确认数字人形象加载完成,控制台无VideoDecoder报错。 - 对着麦克风说“大厨,糖醋排骨怎么做”,观察数字人是否开口播报步骤。
- 播报途中说“等一下,醋放多少”,验证打断是否生效——数字人应立即停止当前播报,回应新问题。
- 说“记住,我不吃香菜”,然后问“推荐个凉菜”,验证 RAG 是否把忌口注入上下文,推荐结果里不应出现香菜。
- 说“记住进度”,然后问“我做到哪了”,验证跨会话记忆。
实测下来,端到端约 500ms 响应,意味着你在灶台前喊一声“下一步该干嘛”,数字人几乎秒回,不会让你等着油烧过头。
5. 本篇常见错排查
5.1 TaoToken 返回 401 / 403
最常见的原因是 Key 没带Bearer前缀,或者 Key 复制时带了空格。检查Authorization头是不是Bearer sk-xxx格式。另一个原因是 Key 被禁用或额度耗尽,去控制台 API Keys 页面确认状态。
5.2 流式返回乱码或截断
TextDecoder必须带{ stream: true },否则多字节字符会被截断。另外buffer要保留最后一行不完整的data:,等下一个 chunk 拼上再解析。上面llm.service.ts里的写法已经处理了这两点。
5.3 数字人 SDK 报 VideoDecoder is not defined
SDK 只支持localhost或https访问。如果你用局域网 IP(比如192.168.x.x)访问,浏览器会禁用某些媒体 API。解决办法:本地开发用localhost,部署时配 https 证书。
5.4 频繁刷新触发房间限流
数字人 SDK 会占用一个房间连接,频繁刷新页面会导致旧连接没释放、新连接被拒。在onBeforeUnmount里调destroyAvatar(),并在window.beforeunload里也调一次,确保连接释放。
5.5 RAG 检索不到知识库内容
检查search()里的匹配逻辑。中文分词比较粗糙,query.includes(p.content.slice(0, 2))只匹配前两个字。如果用户说“我不吃香菜”存进去,问“推荐凉菜”时匹配不到,需要把偏好内容也做关键词提取。简单做法:存偏好时同时存一个keywords数组,检索时遍历匹配。
5.6 语音打断不生效
打断依赖 SDK 的interactiveidle()方法。如果调用后数字人还在说话,检查是不是speak()的is_end参数没传true,导致 SDK 认为播报还没结束。另外pendingText的状态管理要确保在idle回调里才触发新播报,否则会两个声音叠在一起。
6. 跑通之后,你可以往哪走
这套 Demo 跑通后,最直接的下一步是把 TaoToken 的 Key 换成你自己的,然后按场景调模型。菜谱推理用qwen-plus,意图识别用qwen-turbo降本,复杂菜谱规划可以切到更强的模型——只改settings.json里的defaultModel字段,代码不用动。
如果你要长期做编码类 Agent 或厨房场景的自动化工作流,建议看一下 Coding Plan,它适合需要持续调用、多模型协作的开发场景。如果你只是想先验证模型对话效果,可以直接在模型对话页面试几个菜谱问题,确认回答质量再接入。接入过程中遇到 Key 或通道问题,去 API Keys 页面和接入文档对照排查,比在代码里猜快得多。
厨房只是具身交互的一个场景。车间、展厅、门店——任何双手被占用的地方,Agent 都需要一个“身体”来表达。大模型让 AI 学会了思考,下一步是让 AI 进入终端,以具身的方式与人交互。