跳到主要内容基于通义千问与 OpenAI 库的智能舌诊应用开发 | 极客日志TypeScriptNode.jsAI大前端
基于通义千问与 OpenAI 库的智能舌诊应用开发
本项目展示了一款基于通义千问大模型 API 开发的智能舌诊应用。技术栈涵盖 Nuxt.js 前端、Midway.js 后端及 Supabase 云服务。核心流程包括用户上传舌苔照片至 OSS,通过 qwen-vl-max 视觉模型分析图像特征,再利用 qwen-plus 文本模型生成符合中医规范的 JSON 格式诊断报告。文章详细阐述了前后端交互逻辑、Prompt 工程设计及数据解析方法,展示了大模型在垂直领域应用开发的便捷性。
竹影清风1 浏览 项目介绍
所有的项目都是基于 TailwindCSS 实现了响应式,同时支持网页端和移动端的显示效果。
这期尝试开发的 AI 应用是使用通义千问的大模型 API,开发一个 AI 看舌苔的应用。

整个项目的操作流程比较简单,第一屏用户上传自己的舌头的照片,保存到 OSS 中。然后将 OSS 保存的图片发送给通义千问的大模型(这里采用了 qwen-vl + qwen-max 两个大模型),让大模型生成我们的前端 JSON 数据并返回。

整个项目使用到的技术栈如下:
前端
- Nuxt.js:基于 Vue 的 SSR 和 SSG 框架
- Tailwind CSS:原子性的 CSS 框架,能很方便的实现样式的搭建
- VantUI:轻量、可定制的移动端组件库
- Supabase:目前基于 MemFire Cloud 的 supabase 方案
后端
- Midway.js:阿里出的 Node.js 框架
- Supabase:目前基于的 MemFire Cloud 的 supabase 方案,实现数据库存储、OSS、鉴权
AI
- OpenAI:OpenAI 的 Node.js SDK
- 阿里云 DashScope:支持通义千问的能力直接调用
前端开发
前端使用的是 Nuxt3,首页的前端代码如下,核心是两个组件 UploadForm.vue 和 ReportData.vue。分别对应上面提到的上传页面和报告页面。
<template>
<div class="ai-shetai_page h-full">
<!-- 页面信息 -->
<Head>
<Title>AI 夜市·看看舌苔 Demo</Title>
<Meta
name="description"
content="AI 夜市:一个 AI 看舌苔👅的 Demo 应用,大模型采用的阿里通义千问 qwen-plus(用于文本生成)+ qwen-vl-max(用于图像理解)。"
/>
<Style
type="text/css"
children="body { background-color: white; }"
></Style>
</Head>
<!-- 主体 -->
<main class="h-full flex">
<!-- 左侧 -->
<div class="flex hidden lg:block">
<img
class="h-screen"
style="max-width: fit-content"
src="/images/ai-girl-shetou.png"
alt="doctor-girl"
/>
</div>
<!-- 右侧 -->
<div class="flex-grow h-full overflow-y-auto p-8 lg:px-16 lg:py-14">
<div class="max-w-2xl m-auto">
<h2
class="flex items-center text-3xl lg:text-5xl text-slate-800 font-bold"
>
<span>AI·看看舌苔</span>
<img class="w-12 h-12 rounded-full ml-2" src="/images/logo.png" />
</h2>
<p class="text-sm mt-4 text-slate-700">
一个 AI 看舌苔👅的 Demo 应用,大模型采用的阿里通义千问 qwen-plus(用于文本生成)
+ qwen-vl-max(用于图像理解)。
</p>
<p class="text-sm mt-0 text-slate-700">
大模型 Token 很贵,需充值一丢丢 token 进行体验😚。你如果是开发者👨💻,也可以获取源代码自行部署,目前各大模型都有免费 token!
</p>
<div class="bg-white mt-8 rounded-lg p-4 max-w-full lg:max-w-2xl">
<!-- 上传舌头照片 -->
<UploadForm v-if="type === 'upload'" />
<!-- 舌象分析 -->
<ReportData v-else />
</div>
</div>
</div>
</main>
</div>
</template>
<script setup>
import { toRefs } from "vue";
import useProjectShetaiStore from "@/store/shetai";
const projectShetaiStore = useProjectShetaiStore();
const { type } = toRefs(projectShetaiStore);
</script>
<style>
.ai-shetai_page {
background-image: linear-gradient(
45deg,
#ff9a9e 0%,
#fad0c4 99%,
#fad0c4 100%
);
}
</style>
相关免费在线工具
- RSA密钥对生成器
生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online
- Mermaid 预览与可视化编辑
基于 Mermaid.js 实时预览流程图、时序图等图表,支持源码编辑与即时渲染。 在线工具,Mermaid 预览与可视化编辑在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
上传页面
UploadForm.vue 上传组件所对应的代码如下,上传采用的是 vant 的上传组件。
<template>
<div>
<!-- 上传照片 -->
<div>
<label
for="username"
class="block text-sm font-medium leading-6 text-gray-900"
>上传你的👅照片</label
>
<van-uploader
class="mt-2"
v-model="fileList"
reupload
max-count="1"
accept="image/*"
max-size="5 * 1024 * 1024"
:after-read="afterRead"
@delete="onDelete"
/>
</div>
<!-- 生成报告的按钮 -->
<div class="mt-4">
<van-button
type="primary"
class="!bg-pink-400 border !border-pink-400"
:class="{
'cursor-wait': isGenerating,
}"
:disabled="isDisabled"
@click="projectShetaiStore.generateReport(uploadUrl)"
>生成报告</van-button
>
</div>
<!-- 生成时的 loading 效果 -->
<van-overlay :show="isGenerating">
<div class="mt-[25vh]" @click.stop>
<CubeLoading
textCSS="text-white text-center leading-6 text-sm mt-4"
text="AI 正在分析诊断中🔍<br/> 这一步耗时比较久(可能 30s - 60s),请耐心等待!🙏"
/>
</div>
</van-overlay>
</div>
</template>
<script setup>
import { computed, toRefs } from "vue";
import { BUCEKT } from "@/utils/constants";
import useUpload from "@/composables/useUpload";
import useProjectShetaiStore from "@/store/shetai";
// 上传文件
const { fileList, uploadUrl, afterRead, onDelete } = useUpload(BUCEKT.SHE_TAI);
// 生成报告
const projectShetaiStore = useProjectShetaiStore();
const { isGenerating } = toRefs(projectShetaiStore);
// 是否禁用生成报告的按钮
const isDisabled = computed(() => !uploadUrl.value || isGenerating.value);
</script>
图片上传到 OSS 的逻辑,我是采用的开源的 Supabase 方案,核心的上传代码逻辑封装到了 @/composables/useUpload.js 中。它的代码逻辑如下:
import { ref, toRefs } from "vue";
import moment from "moment";
import { nanoid } from "nanoid";
import { showFailToast, showSuccessToast } from "vant";
import { useSupabase } from "@/composables/useSupabase";
const useUpload = (bucketName) => {
const supabase = useSupabase();
const fileList = ref([]);
const uploadUrl = ref("");
async function uploadImageToOSS(vantFile) {
const { file } = vantFile;
const filename = file.name;
const date = moment().format("YYYY-MM-DD");
const uid = nanoid(10);
const PATH = `images/${date}/${uid}-${filename}`;
const { data, error } = await supabase.storage
.from(bucketName)
.upload(PATH, file, {
cacheControl: "3600",
upsert: false,
});
return {
data,
error,
};
}
async function afterRead(vantFile) {
vantFile.status = "uploading";
const res = await uploadImageToOSS(vantFile);
if (res.error) {
vantFile.status = "failed";
const errorMsg = res.error.error || res.error.message || "上传失败";
showFailToast(errorMsg);
} else {
const { data } = supabase.storage
.from(bucketName)
.getPublicUrl(res.data.path);
const { publicUrl } = data;
vantFile.status = "done";
uploadUrl.value = publicUrl;
showSuccessToast("上传成功");
}
}
function onDelete() {
uploadUrl.value = "";
}
return {
fileList,
uploadUrl,
afterRead,
onDelete,
};
};
export default useUpload;
这边 Supabase 方案采用的是 MemfireDB 的方案,因为它提供了很好的国内访问性,而且开发者具有创建 2 个免费额度的权益。
我很安利独立开发者使用 Supabase 开发项目,因为它提供了数据库、OSS 存储和登录鉴权方案,只需要调用一个函数就能实现后端功能,能够极大的提高我们的开发效率,对前端同学非常友好。
并且不像 Google 提供的 Firebase,国内的访问性不佳,我们可以采用一套技术栈,实现国内和海外的 Supabase 方案同构。
报告页面
上传成功后就可以点击生成报告,因为大模型的响应时间比较久,我这边没有考虑增加 stream 返回数据等相关优化。所以增加了一个 loading 的动效。上传组件 ReportData.vue 的源码如下:
<template>
<div>
<h3 class="font-medium">分析报告</h3>
<div class="mt-4 bg-slate-50 px-4 py-2 rounded-lg text-sm">
{{ comment }}
</div>
<div class="mt-4">
<div
class="flex items-center mt-2 text-sm border px-4 py-4 rounded text-gray-800"
v-for="(item, index) in diagnosis"
:key="item.id"
>
<div class="flex-grow">
<div class="font-medium">{{ item.id }}:{{ item.name }}</div>
<div class="mt-2 flex">
<i class="rounded-full w-1 h-1 bg-pink-500 mt-2 mr-2 flex-none"></i
>特点:{{ item.feature }}
</div>
<div class="mt-2 flex">
<i class="rounded-full w-1 h-1 bg-pink-500 mt-2 mr-2 flex-none"></i
>诊断:{{ item.diagnosis }}
</div>
</div>
<div class="flex-none rounded-full border p-1 border-pink-400 ml-2">
<img class="w-8 h-8 rounded-full" :src="`/images/${index + 1}.jpg`" />
</div>
</div>
</div>
<div class="mt-4 flex justify-end">
<van-button
type="primary"
class="!bg-pink-400 border !border-pink-400"
:class="{
'cursor-wait': isGenerating,
}"
@click="() => projectShetaiStore.clearCache()"
>再测一次</van-button
>
</div>
</div>
</template>
<script setup>
import { computed, toRefs } from "vue";
import useProjectShetaiStore from "@/store/shetai";
const projectShetaiStore = useProjectShetaiStore();
const { reportData } = toRefs(projectShetaiStore);
function parseAIJSON(aiContent) {
const content = aiContent.replace(/\\n/g, "");
const regex = /```json\s([\s\S]*?)\s```/;
const matches = content.match(regex);
if (matches && matches.length) {
try {
const jsonString = matches[1];
const data = JSON.parse(jsonString);
return data;
} catch (error) {
console.error(error);
return null;
}
} else {
return null;
}
}
const data = computed(() => {
let json = parseAIJSON(reportData.value);
return json;
});
const comment = computed(() => {
if (data.value && data.value.comment) {
return data.value.comment;
} else {
return "AI 分析失败,请稍后再试。";
}
});
const diagnosis = computed(() => {
if (data.value && data.value.data) {
const keys = Object.keys(data.value.data);
const result = keys.map((key) => {
const item = data.value.data[key];
return {
id: key,
name: item.name,
feature: item.feature,
diagnosis: item.diagnosis,
};
});
return result;
} else {
return [];
}
});
</script>
后端 + 大模型
后端采用的是 Midway,调用通义千问模型前,我们需要去阿里云 DashScope 申请 OpenAIKey,填写到我们的环境变量里面。阿里云送了很慷慨的 token 额度,我们不需要考虑费用问题,根本用不完!
openai 库
通义千问的大语言模型是支持了 openai 库的兼容的(qwen-vl 视觉模型还暂时不支持),这边你可以调用 /api/test,顺利的话就能够获取到通义千问返回的数据。
import OpenAI from 'openai';
import { Inject, Controller, Get } from '@midwayjs/core';
import { Context } from '@midwayjs/koa';
import { ApiService } from '../service/api.service';
import { env } from '../config/config.env';
@Controller('/api/')
export class APIController {
@Inject()
ctx: Context;
@Inject()
apiService: ApiService;
@Get('/test')
async getTest() {
const isDev = env === 'development';
if (!isDev) {
return {
success: false,
info: '非开发环境不可用,避免 token 消耗',
};
}
const params: OpenAI.Chat.ChatCompletionCreateParams = {
messages: [{ role: 'user', content: '你好啊,你是谁?你能做什么?' }],
model: 'qwen-plus',
};
return this.apiService.getQwenChat(params);
}
}
编写 prompt
这边的 prompt 核心逻辑都在 /api/shetai 这个 controller 所对应的代码中。
import { Inject, Controller, Post, Body } from '@midwayjs/core';
import { Context } from '@midwayjs/koa';
import OpenAI from 'openai';
import { IQwenVlData } from '../types/index';
import { ShetaiService } from '../service/shetai.service';
const SHENZHI_DATA = require('../jsons/shezhi.data.json');
const SHETAI_DATA = require('../jsons/shetai.data.json');
@Controller('/api/shetai')
export class APIController {
@Inject()
ctx: Context;
@Inject()
apiService: ShetaiService;
@Post('/diagnosis')
async getDiagnosis(@Body() body: any) {
const { imageUrl } = body;
if (!imageUrl) {
return {
success: false,
info: '缺少图片参数',
};
}
const vlBody = {
model: 'qwen-vl-max',
parameters: {
top_p: 0.7,
top_k: 50,
},
input: {
messages: [
{
role: 'user',
content: [
{
image: imageUrl,
},
{
text: `假设你是一个专业的老中医,非常擅长进行舌诊,你目前正在填写我们医院规定的舌诊病例,病例要求填写的 5 项方面(苔质、苔色、舌色、舌形、舌神)。你需要从图片中仔细的观察用户的舌头情况,并填写完成这 5 项。
1. 舌质:舌苔的厚薄,是否能看到舌体本身?舌苔是否湿润,适中还是干燥粗糙?舌苔是否细腻不易挂去?并给出结论舌质是薄苔,厚苔,润苔,滑苔,燥苔,糙苔,腻苔,腐苔,剥苔,类剥苔,花剥苔,地图舌,镜面舌哪种?
2. 苔色:舌苔的颜色是白色苔、薄白苔、薄白润苔、薄白干苔、薄白滑苔、白厚苔、白厚燥苔、白厚腻苔、积粉苔、薄黄苔、深黄苔、焦黄苔、黄腻苔、黄燥苔、灰黑干苔,还是灰黑润苔?
2. 舌色:舌头的颜色是淡红舌,淡白舌,枯白舌,红舌,绛舌,紫舌,绛紫舌,淡紫舌,瘀斑、瘀点舌,青舌?是否有斑块?
3. 舌形:判断是老舌 (舌头表面粗糙,颜色暗淡,看起来较老), 淡嫩舌,红嫩舌,胖大舌,齿痕舌,肿胀舌,红瘦舌,淡瘦舌,点、刺舌,裂纹舌(舌头表面有不同形状的裂纹,有的被舌苔覆盖,有的没有)
4. 舌神:判断舌头的神态是荣舍 (舌头颜色鲜红且有光泽,活动自如) 还是枯舌 (舌头颜色暗淡无光,活动不灵活)?
`,
},
],
},
],
},
};
const qWenVLData: IQwenVlData = await this.apiService.getQwenVLMax(vlBody);
if (qWenVLData) {
const { usage, output } = qWenVLData;
this.ctx.logger.info('qwen-vl-max 模型的 token 消耗:', usage);
const params: OpenAI.Chat.ChatCompletionCreateParams = {
messages: [
{
role: 'system',
content: `你是一个专业的老中医,非常擅长进行舌诊,通过观察舌象以了解病情的诊察方法。所谓舌象,是指舌质和舌苔这两大块的形象。
舌质包括:舌神、舌色、舌形三类。这三类中又具有具体的判定标准,数据如下的 JSON: \`\`\`json\n${JSON.stringify(
SHENZHI_DATA
)}\n\`\`\`
舌态包括:苔质、苔色两类。这两类中又具有具体的判定标准,数据如下的 JSON: \`\`\`json\n${JSON.stringify(
SHETAI_DATA
)}\n\`\`\`
你需要根据用户传来的对舌头的描述内容,来判断最符合舌质和舌态的具体情况。并返回以下的 JSON 格式给到用户,JSON 的格式符合 typescript 的interaface 接口,定义如下:
interface DiagnosisResponse {
comment: string,
data: {
// 苔质
苔质:{
name: string;
feature: string;
diagnosis: string;
};
// 苔色
苔色:{
name: string;
feature: string;
diagnosis: string;
},
// 舌神
舌神:{
name: string;
feature: string;
diagnosis: string;
};
// 舌色
舌色:{
name: string;
feature: string;
diagnosis: string;
};
// 舌形
舌形:{
name: string;
feature: string;
diagnosis: string;
};
};
};
其中的 comment 字段需要包含下面 4 点,请都放在 comment 字段中成为一段话!:
1.总结用户舌头的特点
2.诊断状态
3.饮食建议
4.生活方式建议
其中的 data 字段是苔质、苔色、舌神、舌色、舌形的具体诊断情况的映射关系,你需要根据用户的描述内容,来判断最符合的一个判定标准,请务必按照 JSON 的格式返回,否则我会被开除医院的!
`,
},
{
role: 'user',
content: output.choices[0].message.content[0].text,
},
],
model: 'qwen-plus',
};
const qWenMaxData = await this.apiService.getQwenChat(params);
const content = qWenMaxData.choices[0].message?.content;
this.ctx.logger.info('qwen-max 模型的 token 消耗:', qWenMaxData?.usage);
return {
success: !!content,
data: content,
};
} else {
return {
success: false,
info: '调用 qwen-vl-max 失败',
};
}
}
}
可以看到我分别调用了 qwen-vl-max 和 qwen-plus 两个模型。
qwen-vl-max 是通义千问的视觉理解模型,它也支持文字大模型的能力,我本来尝试的是使用它直接理解照片,并且给出前端需要的 JSON。但是我发现它的文本生成能力没有 qwen-plus 稳定和出色,经常会出现给不出指定的 JSON,甚至开始胡说的问题,但是使用 qwen-plus 就基本不会出现。
顺便说下如何让大模型输出 JSON,然后在服务端和后端解析 JSON 这方面在 AI 应用的开发中是十分重要的。有不了解的朋友可看《大语言模型下的 JSON 数据格式交互》,讲的很明白!
这是后端返回的数据,我们前端拿到进行一定程度的文本解析就可以使用。
function parseAIJSON(aiContent) {
const content = aiContent.replace(/\\n/g, "");
const regex = /```json\s([\s\S]*?)\s```/;
const matches = content.match(regex);
if (matches && matches.length) {
try {
const jsonString = matches[1];
const data = JSON.parse(jsonString);
return data;
} catch (error) {
console.error(error);
return null;
}
} else {
return null;
}
}
总结
这是一个相对比较简单的 AI 应用 Demo,但是也正是因为 AI 大模型的加持,才能让整个开发过程变得如此的简单,如果没有大模型提供的 API 能力,后端实现部分还是相对而言很复杂的。
我们不仅需要训练一个能够识别舌苔的视觉理解模型,让视觉理解模型输出这个舌象的特征数据。我们还要在舌诊的数据库中,自己根据特征数据,手挫对应的特征结果。
可以看到,目前在 AI 大模型的加持下,前端开发一些有趣、个性化的应用变得真的十分简单。之后我会基于国内的大模型(豆包、Kimi、通义、智谱清言等),并结合 LangChain 的开源工具,开发更多有意思的大模型应用。