1. 为什么要在 ASP.NET MVC 里塞进一个本机 LLM 工程
先把场景说清楚。你手上有一个跑在 Visual Studio 里的 C# ASP.NET MVC 项目,可能是企业内部的管理后台、工单系统、内容平台,也可能是某个垂直行业的业务中台。现在你想让它具备"看懂图片""理解视频""自动生成摘要或标签"的能力,而且不想把数据往外部服务上送,或者至少希望核心链路可控。这就是"本机 LLM 工程"要解决的问题。
这里说的"本机"有两层含义。第一层是调用侧在本机:你的 C# 代码通过 HTTP 或 SDK 调用一个 LLM 服务,这个服务可以跑在同一台机器的 GPU 上,也可以跑在内网的另一台推理服务器上。第二层是工程侧在本机:整个项目的编排、缓存、重试、日志、鉴权、限流都在你的 ASP.NET MVC 工程里完成,而不是散落在一堆脚本里。很多人一开始只想着"调个 API 而已",结果做到后面发现图片要预处理、视频要抽帧、结果要落库、失败要补偿,最后变成一坨没法维护的代码。所以思路一定要在动手前理清楚。
阿里系的图像 LLM 和视频 LLM 在这类场景里是比较常见的选择,原因很实际:图像理解、视频理解这类多模态能力,自建成本极高,而通过云端多模态接口调用,配合本机的业务编排,是性价比最高的路径。关键词里出现的"阿里云百炼 API 调用示例""阿里云认证 SDK"其实都指向同一件事——用成熟的云侧多模态能力,喂给你本机的 MVC 工程。
这篇文章适合谁看?如果你是会写 C#、用过 ASP.NET MVC、但对 LLM 工程化还没形成完整认知的开发者,这篇就是给你写的。如果你已经在调多模态接口,但代码越写越乱、重试和缓存全靠拍脑袋,这篇也能帮你把结构重新捋一遍。我不会只给你一段"能跑"的代码,而是把每个设计决策背后的理由讲透,包括我踩过的坑。
2. 整体架构:MVC 工程与 LLM 服务之间的那条边界
2.1 三层职责划分:Controller 只做编排,不做推理
很多人第一版代码会把调用 LLM 的逻辑直接写在 Controller 的 Action 里,图省事。我早期也这么干过,结果是:一个 Action 三百行,图片 base64 编码、HTTP 请求、JSON 解析、异常处理全糊在一起,单元测试根本没法写,换个模型要改半个控制器。
正确的做法是分三层:
- Controller 层:只负责接收请求、参数校验、调用 Service、返回视图或 JSON。它不应该知道 LLM 的存在,更不应该知道用的是哪家模型。
- Service 层:业务编排层。它决定"这张图要不要先压缩""视频要不要抽帧""结果要不要缓存""失败了重试几次"。这一层是工程的核心。
- Client 层:纯粹的 LLM 通信层。封装 HTTP 调用、鉴权、超时、序列化。换模型时只改这一层。
这样分的好处是,当你想从图像 LLM 换成另一个供应商,或者从云端切到本机推理服务时,改动被限制在 Client 层,Service 和 Controller 几乎不动。这是"面向接口编程"在多模态场景下最实在的价值。
2.2 同步还是异步:ASP.NET MVC 的坑要先填
ASP.NET MVC(注意是经典 MVC,不是 ASP.NET Core)默认是同步管道。如果你在 Action 里直接.Result或.Wait()去等一个 HTTP 调用,在高并发下会线程池饥饿,表现为"平时好好的,一压测就卡死"。这是经典 MVC 里最经典的坑之一。
我的建议是:从 Controller 到 Client 全链路 async/await。Controller 的 Action 返回Task<ActionResult>,Service 方法返回Task<T>,Client 用HttpClient的异步方法。注意HttpClient要单例复用,不要每次new,否则会耗尽 socket。在经典 MVC 里可以用静态字段或者简单的单例容器持有。
public class LlmClient { private static readonly HttpClient _http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) }; public async Task<string> CallVisionAsync(string imageUrl, string prompt) { var payload = new { model = "vision-model", image = imageUrl, prompt = prompt }; var content = new StringContent( JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json"); var resp = await _http.PostAsync(_endpoint, content); resp.EnsureSuccessStatusCode(); return await resp.Content.ReadAsStringAsync(); } }超时设 120 秒是有讲究的。图像理解通常几秒到十几秒,但视频理解如果走抽帧再逐帧分析,可能几十秒。设太短会频繁超时,设太长会拖垮线程。我一般给图像 30 秒、视频 180 秒,分开配置。
2.3 配置与密钥:别把 AccessKey 写进代码
关键词里出现了"阿里云认证 SDK""阿里云 SSL",说明鉴权是绕不开的。最忌讳的就是把 AccessKeyId 和 AccessKeySecret 硬编码在.cs文件里然后提交到代码仓库。我见过不止一个项目这么干,后来密钥泄露被刷爆账单。
正确做法是放在Web.config的appSettings里,或者用环境变量,生产环境用配置中心。读取时封装一个LlmOptions类,集中管理 endpoint、key、model 名称、超时、重试次数。这样换环境只改配置,不改代码。
提示:密钥轮换要留后路。把密钥读取封装成方法而不是静态字段,将来接入配置中心动态刷新时不用重构。
3. 图像 LLM 调用:从一张图到一段结构化结果
3.1 图片进入 LLM 之前的预处理,决定了成败
直接把手机关拍的 5MB 原图丢给图像 LLM,是最常见的错误。原因有三:一是传输慢,二是很多接口对图片大小和分辨率有上限,三是过大的图并不会提升理解质量,反而可能因为缩放导致细节丢失。
我的标准预处理流程是这样的:
- 格式统一:统一转成 JPEG 或 PNG。JPEG 体积小,适合照片;PNG 适合带文字的截图。
- 尺寸压缩:长边压到 1568 像素左右(这是很多多模态模型的有效分辨率上限附近)。用
System.Drawing或ImageSharp做等比缩放。 - 质量压缩:JPEG 质量设 80 左右,肉眼几乎无差别,体积能降一半以上。
- 编码方式选择:小图直接 base64 内联,大图先上传到对象存储拿 URL 再传 URL。base64 会让请求体膨胀约 33%,超过 1MB 的图我倾向走 URL。
public static byte[] ResizeToJpeg(byte[] input, int maxSide = 1568, int quality = 80) { using (var ms = new MemoryStream(input)) using (var img = Image.FromStream(ms)) { double ratio = Math.Min(1.0, (double)maxSide / Math.Max(img.Width, img.Height)); int w = (int)(img.Width * ratio), h = (int)(img.Height * ratio); using (var bmp = new Bitmap(img, w, h)) { var codec = ImageCodecInfo.GetImageEncoders() .First(c => c.FormatID == ImageFormat.Jpeg.Guid); var pars = new EncoderParameters(1); pars.Param[0] = new EncoderParameter(Encoder.Quality, (long)quality); using (var outMs = new MemoryStream()) { bmp.Save(outMs, codec, pars); return outMs.ToArray(); } } } }这段代码在经典 MVC 里能直接用,System.Drawing是框架自带的。注意Bitmap和Image都要using释放,否则在批量处理时会内存暴涨——这是我在一个批量打标任务里踩过的坑,跑了两千张图后进程直接 OOM。
3.2 Prompt 设计:让模型输出你能解析的结构
图像 LLM 最怕的就是返回一段自由文本,你还得写正则去抠字段。解决办法是在 Prompt 里明确要求 JSON 输出,并给出字段定义和示例。
比如做商品图理解,我会这样写:
你是一个商品图像分析助手。请分析这张图片,严格按以下 JSON 格式输出,不要输出任何其他内容: { "category": "商品大类", "attributes": ["属性1", "属性2"], "has_text": true/false, "text_content": "图中文字,没有则为空字符串", "confidence": 0.0-1.0 }关键点有三个:一是明确"不要输出其他内容",否则模型爱加"好的,以下是分析结果"这种前缀;二是给出字段类型和取值范围;三是给一个示例(few-shot),能显著提升格式稳定性。
即便如此,仍会有小概率返回带 markdown 代码块包裹的 JSON。所以解析时要先剥掉json 和,再反序列化,并且用 try-catch 兜底,解析失败就记录原始返回,方便排查。
3.3 结果落库与幂等:同一张图不要重复花钱
图像理解是按量计费的,同一张图重复调用就是浪费。我的做法是:对图片内容算一个哈希(比如 SHA256),以哈希为 key 缓存结果。缓存可以放内存(MemoryCache)、Redis 或数据库。命中缓存直接返回,不调接口。
string key = "img:" + Sha256(bytes); var cached = _cache.Get(key) as string; if (cached != null) return cached; var result = await _client.CallVisionAsync(...); _cache.Set(key, result, TimeSpan.FromDays(7));哈希要基于预处理后的字节还是原始字节?我建议基于原始字节,因为预处理参数可能调整,用原始字节能保证"同一张原图永远同一个 key"。这个细节不注意,改了压缩参数后缓存全部失效,白白多花钱。
4. 视频 LLM 调用:抽帧、分段与时间轴对齐
4.1 视频不能直接喂,抽帧策略是核心
视频 LLM 有两种用法:一种是直接把视频文件传给支持视频的多模态接口,另一种是自己抽帧成图片序列再逐帧或抽样分析。前者省事但可控性差,后者灵活但工程量大。我一般根据场景选:
- 短视频(<1 分钟)、要整体理解:直接传视频接口。
- 长视频、要定位具体片段:自己抽帧,按时间轴分析。
- 只要关键信息:按固定间隔抽帧(比如每 2 秒一帧),再对帧做图像理解。
抽帧工具在 Windows 环境下可以用 FFmpeg 命令行,C# 里用Process.Start调用。抽帧命令大致是:
ffmpeg -i input.mp4 -vf fps=1/2 -q:v 2 frame_%04d.jpgfps=1/2表示每 2 秒一帧。-q:v 2控制 JPEG 质量。抽出来的帧按序号命名,序号乘以间隔就是大致时间点。
4.2 帧太多怎么办:分层抽样与去重
一个 10 分钟的视频,每 2 秒一帧就是 300 帧。全丢给图像 LLM,成本和耗时都受不了。我的策略是分层抽样:
- 先按较大间隔(比如每 10 秒)抽一批"粗帧",快速过一遍,判断哪些时间段有内容变化。
- 对变化大的时间段,再加密抽帧。
- 对连续高度相似的帧做去重(比如计算相邻帧的感知哈希,差异小于阈值就丢弃)。
感知哈希可以用简单的均值哈希实现:把图缩到 8x8 灰度,算平均值,每个像素大于均值记 1 否则记 0,得到 64 位指纹。两帧指纹的汉明距离小于 5 就认为是相似帧。这个算法几十行代码就能写完,效果对"画面基本没变"的场景足够用。
4.3 时间轴对齐:让结果能定位回原视频
视频理解的输出如果只是"这个视频讲了什么",价值有限。真正有用的是"第 30 秒出现了产品 logo""第 2 分钟有人在讲解参数"。所以每一帧的分析结果都要带上时间戳。
我的做法是维护一个FrameResult列表,每项包含TimeSeconds、FramePath、Analysis。最后按时间排序,合并相邻的相似结论,输出一条带时间轴的结构化结果。这样前端可以做成"点击结论跳转到对应画面"的交互,体验完全不一样。
注意:抽帧得到的帧文件要及时清理。我见过一个项目把抽帧图全留在磁盘上,跑了一个月磁盘爆了。分析完就删,或者放到临时目录定期清理。
5. 训练与微调的思考:什么时候该动手,什么时候别碰
5.1 先问自己:提示词工程解决不了吗
一提到 LLM,很多人第一反应是"我要微调一个自己的模型"。但现实是,80% 的业务场景用提示词工程 + few-shot 就能解决,根本不需要微调。微调的成本不只是训练那点算力,还有数据标注、评估、版本管理、上线部署,是一整套工程。
我的判断标准很简单:如果你要模型做的是"格式转换""信息抽取""分类打标"这类任务,先花两天把 Prompt 打磨到极致,加几个示例,大概率就够了。只有当任务涉及特定领域的专业术语理解、固定的输出风格、大量私有知识,且提示词怎么调都达不到要求时,才考虑微调。
5.2 如果真要微调,数据准备比训练本身重要十倍
微调的效果几乎完全取决于数据质量。我见过太多人随便凑几百条数据就开训,结果模型学了一堆噪声。正确的数据准备流程是:
- 数据来源:从真实业务日志里捞,而不是自己编。真实数据才有真实的分布和边界情况。
- 数据清洗:去掉重复、矛盾、格式错误的样本。矛盾样本对模型是毒药。
- 格式统一:输入输出严格按训练框架要求的格式(比如 JSONL,每行一个
{"prompt": ..., "completion": ...})。 - 划分验证集:至少留 10% 做验证,否则你根本不知道模型是记住了还是学会了。
数据量方面,指令微调通常几百到几千条高质量样本就能看到效果,关键是质量不是数量。一千条精标数据,胜过一万条脏数据。
5.3 本机微调的现实约束
如果你想在本机做微调,先看显卡。7B 参数的模型做 LoRA 微调,至少需要 16GB 显存(用 4-bit 量化能压到 10GB 左右)。13B 以上基本要 24GB 起步。没有这个硬件条件,就别在本机折腾,老老实实用云端的微调服务,或者干脆走提示词路线。
另外,微调后的模型部署和推理是另一套工程。你要考虑推理框架(比如 vLLM、TGI 这类)、并发、显存占用、模型版本切换。这些和你的 ASP.NET MVC 工程怎么对接,又是一轮新的设计。所以我的建议是:微调是最后手段,不是第一选择。
6. 工程化细节:让这套东西在生产环境活下来
6.1 重试与退避:网络抖动是常态
调用云端 LLM 接口,超时和 5xx 错误是家常便饭。无脑重试会雪上加霜,正确做法是指数退避 + 抖动。第一次失败等 1 秒,第二次 2 秒,第三次 4 秒,每次加一点随机抖动避免同时重试。重试次数控制在 3 次以内,超过就记录失败进死信队列,人工介入。
要注意区分错误类型:4xx 通常不该重试(参数错了重试也没用),429 限流要退避更久,5xx 和超时才重试。这个判断逻辑写在 Client 层,Service 层不用关心。
6.2 日志与可观测性:出问题能查
LLM 调用最怕"线上出问题但查不到原因"。我的日志策略是:每次调用记录请求 ID、模型名、输入摘要(图片记哈希不记内容)、耗时、token 消耗、返回状态。输入内容不要全量记,一是日志体积大,二是可能含敏感信息。
耗时和 token 消耗要单独统计,做成看板。这样你能清楚知道钱花在哪、哪个接口慢。我有个项目就是通过耗时统计发现视频接口在特定时段特别慢,后来调整了调用时段,体验明显改善。
6.3 限流与并发控制:别把自己打挂
如果你的 MVC 站点有并发用户,每个用户请求都触发一次 LLM 调用,很容易把接口配额打满或者把本机推理服务压垮。要在 Service 层做并发控制,用SemaphoreSlim限制同时进行的 LLM 调用数量。
private static readonly SemaphoreSlim _gate = new SemaphoreSlim(5); public async Task<string> AnalyzeAsync(byte[] img) { await _gate.WaitAsync(); try { return await _client.CallVisionAsync(img); } finally { _gate.Release(); } }并发数设多少?取决于你的接口配额和本机推理能力。云端接口看 QPS 限制,本机推理看显存和批处理能力。宁可设小一点排队,也不要设大了把服务打挂。
7. 我在实际项目里踩过的几个坑
第一个坑是图片 base64 编码后请求体过大导致 413。当时没做尺寸压缩,用户上传的原图直接编码,超过网关限制被拒。后来加了预处理,问题消失。教训是:永远不要相信用户上传的图片尺寸。
第二个坑是视频抽帧的临时文件没清理。跑批量任务时磁盘写满,整个服务挂了。后来改成分析完立即删除,并且用独立的临时目录,加了磁盘空间监控。
第三个坑是Prompt 里的 JSON 示例被模型"抄"进输出。我在 Prompt 里给了一个示例 JSON,结果模型有时候把示例本身也输出了。解决办法是把示例和指令用明确的分隔符隔开,并且在指令里强调"仅输出一个 JSON 对象"。
第四个坑是缓存 key 设计不当导致串数据。早期我用图片文件名做 key,结果不同用户上传了同名文件,返回了别人的分析结果。改成内容哈希后再没出现过。这个坑很隐蔽,因为测试时很难复现,只有真实多用户场景才暴露。
第五个坑是异步方法里用了同步锁。在 async 方法里用lock会导致死锁风险,应该用SemaphoreSlim。这个是我在代码审查时被同事指出的,当时完全没意识到。
8. 关于模型选型的一点个人看法
图像和视频 LLM 的选型,不要只看榜单分数。榜单上的分数是在标准数据集上测的,和你的实际业务数据分布可能差很远。我的做法是:拿一批自己业务的真实样本,做小规模对比测试。同一批图,分别用两三个候选模型跑,人工评估结果质量,再结合成本和延迟做决定。
另外,多模态模型的更新很快,今天的最优解可能三个月后就变了。所以架构上一定要把模型调用抽象成接口,换模型时只改配置和 Client 层。这是我在多个项目里验证过的、最省心的做法。
还有一点,别迷信"参数越大越好"。很多业务场景,一个中等规模的模型配合好的 Prompt,效果不输大模型,但成本和延迟低得多。先用小模型跑通,效果不够再往上换,这个顺序不要反。
最后分享一个实用技巧:给 LLM 调用加一个"降级开关"。当云端接口不可用或者成本超预算时,能一键切到备用模型或者返回缓存结果。这个开关在配置里控制,不用改代码不用重新部署。生产环境里,这种"能兜底"的设计比追求极致效果更重要。