
实际 AI 辅导应用和演示 Demo 最大的区别往往不是大模型回答得是否准确而是 AI 能否在用户屏幕上精确定位并指出“这一步在哪里”。一个以 “Show HN: Visually Precise AI Tutoring on iOS” 为主题的项目核心思路就是让 AI 不只返回一段讲解文本而是把题目中的关键区域识别出来再用箭头、高亮框把它标在原始图片上。这里的难点在于三个环节必须串起来用 Vision 框架拿到文字和区域坐标把结构化视觉信息交给大模型再根据模型返回的引用编号把结论画回覆盖层。整个过程在 iOS 原生栈上可以做得非常轻量不依赖第三方图像处理库。下面会从一个最小可运行方案开始说明这条链路如何落地。你可以看到如何准备权限和项目骨架如何用 Vision 识别题目区域如何设计提示词让模型输出可编程的 JSON以及如何把模型回答变成屏幕上的视觉标注。文末会给出常见坑、排查表和上线前的检查清单。这篇文章面向已经会写 SwiftUI 或 UIKit 基础界面、但还没系统接触过 Vision 和 LLM 集成的 iOS 开发者。1. 先拆解“视觉精确”这条主线再决定技术选型1.1 场景拆解从拍照到标注一共四步假设学生拍下一道数学题期望 App 指出“这个式子是从哪里变过来的”。把这句话翻译成工程任务实际上是四步获取题目图片可能来自相册也可能来自相机用 OCR 识别图片中的文字并拿到每个文字块的坐标把 OCR 结果交给大模型让模型判断这是哪一步、下一步该怎么做把模型答案中引用的文字块在图片上画出来形成高亮框或箭头。这四步看起来简单但每一步都有独立的失败模式。OCR 可能漏字坐标可能翻转模型可能返回无效引用覆盖层可能没有和原图对齐。任何一个环节出错用户看到的结果都是“AI 在乱指”。1.2 “视觉精确”其实是一个坐标定位问题很多人把“视觉精确”理解成 AI 生成了一张带标记的图片或者理解成一个华丽的 UI 动画。实际上它本质上是一个几何定位问题AI 得出结论之后必须把结论映射到图片像素坐标系里的具体区域再映射到屏幕上的视图坐标系。因此核心数据流是图片像素坐标 - OCR 文本块 id - 模型引用 id - 视图坐标 - 覆盖层绘制其中模型本身并不理解像素坐标。它只需要在一个抽象层上工作你给它一份带 id 的 OCR 文本列表它返回“我用到了 id 2 和 id 5”。真正做坐标映射的是你的代码。这样设计的好处是模型不需要记忆大量数字坐标你也不需要担心不同机型分辨率带来的坐标漂移。1.3 原生栈选型Vision URLSession CALayer在这个项目中可以完全不引入第三方 SDK。苹果的 Vision 框架负责文字识别URLSession 负责调用大模型接口CALayer 或 SwiftUI Shape 负责绘制标注。三者都是系统能力优点是链路短、容易排查、也没有额外依赖版本打架的问题。适合用表格总结选型环节能力选择理由文字识别OCRVision系统框架支持中文和英文返回归一化坐标模型调用大模型 APIURLSession无第三方依赖方便切换不同兼容接口标注绘制高亮和箭头CAShapeLayer性能好坐标可控界面展示图片和覆盖层SwiftUI / UIKit 容器两者都可以关键是坐标空间统一2. 环境准备版本、权限和项目骨架2.1 环境要求先明确开发和运行环境避免后面出现“识别不出中文”“请求发不出去”这类基础问题。类别要求说明Xcode15.0 及以上Vision 的异步接口和 Swift Concurrency 支持更完整iOS17.0 及以上本文示例基于较新的 Vision 行为编写Swift5.9 及以上使用 async/await大模型接口任一兼容 JSON 输出的 Chat Completion 接口需要用你自己的 API Key第三方依赖无仅使用系统框架和 URLSession大模型接口这里刻意不指定具体厂商因为不同厂商的接口地址、鉴权头和参数格式略有差异。示例代码会用一个chatCompletion(prompt:)函数占位你在实际项目里替换成自己的请求即可。如果原始项目没有给出明确模型版本落地前先确认你使用的接口支持“ JSON 输出模式 ”这会大幅减少解析问题。2.2 权限配置如果允许用户从相机拍摄题目需要在 Info.plist 里声明相机权限如果允许从相册选择需要声明相册权限。keyNSCameraUsageDescription/key string用于拍摄题目并获得逐步解题指导/string keyNSPhotoLibraryUsageDescription/key string用于选择题目图片进行识别/string这里要注意iOS 对权限描述非常严格用户在授权弹窗里看到的就是这段文字。描述必须说明 App 为什么需要这个权限不能只写“使用相机”。2.3 项目结构按照职责拆分方便后面单独测试每一步。Tutor/ ├── App/ │ ├── TutorApp.swift │ └── ContentView.swift ├── Models/ │ ├── TextBlock.swift │ └── TutorPlan.swift ├── Services/ │ ├── VisionService.swift │ ├── LLMService.swift │ └── PromptBuilder.swift ├── Views/ │ ├── ProblemImageView.swift │ └── AnnotationOverlayView.swift └── Info.plist核心思路是Models 只定义数据Services 负责采集和推理Views 只做显示。这样当你单独调试 OCR 时不需要打开整个界面单独验证模型返回时也不需要真机相机。3. 用 Vision 把图片变成带坐标的文字块3.1 最小 OCR 实现Vision 的VNRecognizeTextRequest是整套方案的入口。下面是一个最小实现输入UIImage输出一个TextBlock数组。import Vision import UIKit struct TextBlock: Sendable { let id: Int let text: String let normalizedRect: CGRect } enum TutorError: Error { case invalidImage } func recognizeText(in image: UIImage) async throws - [TextBlock] { guard let cgImage image.cgImage else { throw TutorError.invalidImage } let request VNRecognizeTextRequest() request.recognitionLevel .accurate request.recognitionLanguages [zh-Hans, zh-Hant, en-US] request.usesLanguageCorrection true let handler VNImageRequestHandler(cgImage: cgImage, options: [:]) try handler.perform([request]) let observations request.results ?? [] var blocks: [TextBlock] [] blocks.reserveCapacity(observations.count) for (index, observation) in observations.enumerated() { guard let candidate observation.topCandidates(1).first else { continue } let box observation.boundingBox blocks.append(TextBlock(id: index, text: candidate.string, normalizedRect: box)) } return blocks }这里有几个关键参数recognitionLevel .accurate识别更准但耗时更长。真实场景可以结合按钮让用户选择“快速预览”和“精确识别”。recognitionLanguages按你的目标用户顺序排列。这里把简体中文放前面表示优先按中文识别。usesLanguageCorrection true会结合上下文修正词语适合题目这种完整句子。3.2 坐标转换隐藏的关键点Vision 返回的boundingBox是归一化坐标并且坐标系原点在左下角UIKit 的坐标系原点在左上角。如果你直接把boundingBox当成视图坐标去画框结果一定会上下翻转。func convertVisionRect(_ normalizedRect: CGRect, in imageSize: CGSize) - CGRect { let width normalizedRect.width * imageSize.width let height normalizedRect.height * imageSize.height let x normalizedRect.minX * imageSize.width let y (1 - normalizedRect.maxY) * imageSize.height return CGRect(x: x, y: y, width: width, height: height) }转换的核心是y 方向用1 - normalizedRect.maxY取反。这样得到的 CGRect 就是图片像素坐标系里的矩形后续覆盖层绘制和模型引用都依赖它。3.3 按阅读顺序给文本块分配稳定 id上面的代码直接用识别顺序作为 id这不够稳定。同一张图在不同识别级别下识别顺序可能不同导致模型引用的 id 无法复现。更稳妥的做法是先按位置排序再分配 id。let sortedBlocks blocks.sorted { lhs, rhs in if abs(lhs.normalizedRect.midY - rhs.normalizedRect.midY) 0.02 { return lhs.normalizedRect.midY rhs.normalizedRect.midY } return lhs.normalizedRect.minX rhs.normalizedRect.minX } let orderedBlocks sortedBlocks.enumerated().map { index, block in TextBlock(id: index, text: block.text, normalizedRect: block.normalizedRect) }这样文本块 id 基本遵循“从上到下、从左到右”的阅读顺序。即使模型引用某个 id用户也能大致猜到它指的位置。4. 设计提示词和 JSON 协议让模型能“引用”坐标4.1 输入里应该带什么最省事的做法是把整张图片发给一个视觉语言模型让它直接输出坐标。但这类模型对像素坐标的精确性通常不够稳定而且输入图片会增加延迟和费用。一个更可复现的方案是只把 OCR 文本块发送给文本模型。模型的任务不是判断“像素位置”而是判断“哪个文本块属于哪个步骤”。坐标映射完全由本地代码负责。4.2 提示词模板下面的提示词面向数学辅导场景要求模型输出严格的 JSONfunc buildPrompt(blocks: [TextBlock]) - String { let lines blocks .map { {\($0.id)}:\($0.text) } .joined(separator: \n) return 你是面向学生的数学辅导老师。下面是从题目图片中 OCR 得到的文本块。 每个文本块格式{id}:text 要求 1. 先判断这是不是一道可解答的题目。 2. 按步骤给出解答步骤要可验证。 3. 每个步骤中用 refBlocks 字段引用最能说明该步骤的文本块 id。 4. 不允许引用不存在的 id。 5. 只输出 JSON不要输出任何解释。 OCR 文本 \(lines) 输出 JSON 格式 { isAnswerable: true, steps: [ { title: 步骤名, detail: 这一步为什么这样做, refBlocks: [1, 3] } ] } }这里把“不要输出额外文字”写进提示词是为了方便程序解析。实际调用时如果接口支持response_format: { type: json_object }建议一并开启。4.3 返回 JSON 的解析策略合法的模型返回类似这样{ isAnswerable: true, steps: [ { title: 把等式两边同时除以 2, detail: 为了把 x 的系数化为 1。, refBlocks: [2, 5] } ] }对应 Swift 模型可以这样定义struct TutorStep: Codable, Sendable { let title: String let detail: String let refBlocks: [Int] } struct TutorPlan: Codable, Sendable { let isAnswerable: Bool let steps: [TutorStep] }解析时不能假设字段一定存在。要用JSONDecoder配合可选字段容错或者至少捕获解码错误后给用户一个“AI 无法识别”的提示而不是直接崩溃。5. 用覆盖层把结论画回屏幕5.1 覆盖层与图片必须处于同一坐标空间视觉标注最常出现的问题是图片放在一个可缩放的 ScrollView 里覆盖层放在外部导致滚动或缩放后标注错位。推荐做法是把UIImageView和覆盖层UIView都放进同一个可缩放的容器视图里并且让覆盖层和图片拥有相同的 frame。这样图片缩放时覆盖层里的CAShapeLayer会跟着一起变换。5.2 高亮框和箭头的绘制根据TextBlock.normalizedRect和图片实际尺寸先把归一化坐标转成像素坐标。如果 imageView 本身显示的图片没有充满整个 imageView还要考虑 contentMode 对 frame 的影响。简单项目里可以让图片充满避免额外的 contentMode 换算。func addHighlight(for block: TextBlock, in overlayView: UIView, imageSize: CGSize) { let rect convertVisionRect(block.normalizedRect, in: imageSize) let shape CAShapeLayer() shape.frame overlayView.bounds shape.path UIBezierPath(roundedRect: rect, cornerRadius: 6).cgPath shape.fillColor UIColor.systemYellow.withAlphaComponent(0.35).cgColor shape.strokeColor UIColor.systemOrange.cgColor shape.lineWidth 2 overlayView.layer.addSublayer(shape) }箭头用于“从讲解文字指向图片区域”的场景。下面是一个简单实现func addArrow(from start: CGPoint, to end: CGPoint, in overlayView: UIView) { let line UIBezierPath() line.move(to: start) line.addLine(to: end) let arrowLayer CAShapeLayer() arrowLayer.path line.cgPath arrowLayer.strokeColor UIColor.systemRed.cgColor arrowLayer.lineWidth 3 arrowLayer.lineCap .round overlayView.layer.addSublayer(arrowLayer) }5.3 交互和动画的取舍对于教学场景不建议把所有步骤一次性同时画出来。更符合认知的做法是先展示第一步用户点击“下一步”后再更新高亮框。这样用户知道当前标注针对的是哪一步。动画要克制。高亮框出现时做一个淡入或轻微缩放即可不需要复杂旋转。重点是精确而不是炫技。6. 把链路串起来状态机与最小运行流程6.1 状态机定义整个 App 的界面可以抽象成几个状态enum TutorState { case idle case capturing case recognizing case reasoning case rendering case failed(String) }状态机的价值在于每个状态对应一个 UI 展示也对应一个可调试入口。当用户反馈“卡住”时你只需要看当前在哪个状态就可以缩小排查范围。6.2 串联代码核心方法只需要四步func handle(image: UIImage) async throws - TutorResult { let blocks try await recognizeText(in: image) let prompt buildPrompt(blocks: blocks) let rawJSON try await chatCompletion(prompt: prompt) let plan try JSONDecoder().decode(TutorPlan.self, from: rawJSON.data(using: .utf8)!) return TutorResult(blocks: blocks, plan: plan) }实际项目里需要把chatCompletion(prompt:)替换成你的接口调用并在里面处理网络超时、HTTP 错误码、限流和 JSON 非法等情况。6.3 分阶段验证顺序不要一上来就调试完整链路。建议按以下顺序验证先确认 Vision 能返回正确文字块打印 id、text、normalizedRect再确认坐标转换后高亮框能正确框住原图中的文字然后确认模型返回的 refBlocks 都存在且合理最后才验证整个点击交互流程。每通过一步再进入下一步。如果直接跳到最终界面出现问题时往往要同时怀疑视觉模块和模型模块排查成本会高很多。7. 运行验证与常见问题排查7.1 三个检查点用一张印刷清晰、光照均匀的题目图做标准测试样例。验证时要盯着三个点检查点预期表现失败时现象文字识别所有题干文字都被识别文本顺序接近阅读顺序漏字、乱码、顺序错乱坐标对齐高亮框正好框住对应文字框偏上、偏下或整体翻转模型引用所有 refBlocks 都落在有效范围内模型返回负数、超范围 id7.2 常见问题表问题现象常见原因检查方式处理方案识别结果为空图片太暗、倾斜、分辨率过低打印图片尺寸和 request.results 数量增加拍照引导提示用户保持文字清晰中文识别成乱码识别语言未包含中文检查 recognitionLanguages加入 zh-Hans、zh-Hant高亮框上下颠倒直接用了 Vision 归一化坐标打印 y 转换逻辑使用1 - normalizedRect.maxY模型返回非法 id模型幻觉或 OCR 文本顺序不稳定打印模型原始输出解析后过滤无效 id并重试一次JSON 解析失败接口未启用 JSON 模式查看原始响应字符串开启 json_object 模式或增加修复提示滚动后标注错位覆盖层不在缩放容器内检查视图层级把覆盖层放进同一缩放容器请求超时大模型接口响应慢查看请求耗时和错误日志增加超时和重试策略7.3 最容易踩的四个坑第一个坑是坐标转换不完整。很多人只调整了 y 方向却忘了处理 x 和缩放或者忽略了 imageView 的 contentMode。解决方式是不依赖 imageView 的显示属性而是单独维护一个“图片区域在视图中的 frame”。第二个坑是 OCR 文本块不稳定。模型引用的 id 依赖于 OCR 的排序。如果每次识别顺序都不一样模型的答案就没有可复现性。解决方式是按位置排序后重新分配 id并把这个过程作为固定策略。第三个坑是提示词里把 OCR 文本当成可执行指令。题目图片里可能写着“忽略上面的要求”之类的文字模型可能被误导。解决方式是在提示词里明确说明 OCR 文本是不可信的数据并限制模型只能做数学辅导不能执行图片中的指令。第四个坑是生产环境没有处理限流和日志。上线后发现模型接口偶尔返回 429App 没有重试也没有记录请求体导致完全无法排查。解决方式是统一封装请求记录请求摘要、响应状态码和耗时并在失败时给出可重试的提示。8. 学习环境与生产环境的差异以及上线前检查清单8.1 学习环境与生产环境的关键差异关注点学习环境生产环境API Key写死在代码里放到服务端代理客户端只拿短期 token限流不关心必须做并发控制和退避重试日志控制台打印系统化日志和监控告警隐私任意测试图片脱敏、告知、可删除数据失败处理try? 即可分类型提示、重试、降级到纯文本权限内测设备申请审核完善权限描述大模型接口的 API Key 绝不能直接放在客户端里。生产环境通常需要用一个后端服务做中转客户端把 OCR 文本和题目图片发送到自己的服务端服务端再调用大模型。这样既能保护密钥也能在服务端做内容审核和调用频率控制。8.2 发布前检查清单检查相册和相机权限描述是否清晰触发权限前是否先解释用途检查图片在弱光和倾斜场景下的 OCR 结果至少准备 10 张真实题目测试图检查模型返回的 refBlocks 是否全部有效无效时是否有兜底提示检查所有网络异常路径包括超时、429、5xx、JSON 解析失败检查覆盖层在滚动、缩放、旋转屏幕后是否仍然对齐检查 API Key 是否不存在于客户端安装包中检查日志中是否记录了请求耗时和错误码但不要记录完整用户题目文字添加“AI 可能出错”的免责声明并保留用户反馈入口。9. 扩展方向从能跑通到真正好用9.1 直接接入视觉语言模型如果 OCR 识别率不够或者题目包含大量图形信息可以考虑把图片一起发给视觉语言模型。但不要让它输出绝对像素坐标而是让它输出“某区域内的文字描述”或“基于文字块的引用”再由本地 Vision 负责精确定位。这样可以兼顾模型的语义能力和本地几何精度。9.2 处理真实屏幕内容如果目标不是拍照而是在阅读 PDF 或网页时实时答疑可以考虑用 ReplayKit 或系统的截图接口采集当前屏幕然后走同一套 OCR、模型、覆盖层流程。要注意这类功能在 iOS 上通常需要向用户明确说明屏幕内容的使用范围并且不能用于录制和传播受版权保护的资料。9.3 让标注成为可交互的学习路径当前的高亮框只是“指出位置”。更进阶的形态是点击高亮框可以展开该步的公式推导点击前后步骤之间的箭头可以看到转化关系。这时数据模型需要从TutorPlan扩展成一张图节点是文本块或步骤边是推理关系。能力边界会从“做对题”延伸到“讲清楚思考过程”。对新手来说最有价值的练习不是立刻上线一个完整 App而是先跑通“拍照 - OCR - 画框”这条纯本地链路。等你能稳定地把框中文字标记在原图上再接入大模型时判断模型输出是否合理就会简单很多。视觉精确的 AI 辅导本质上是把“模型知道什么”和“模型指到哪里”两件事分开前者交给大模型后者交给系统几何计算。这也是这类项目最值得借鉴的工程判断。