```
refactor: 重命名项目名称从"体己"到"康康" 将整个项目的目录结构从"体己"重命名为"康康",包括所有源代码文件、 资源文件、测试文件以及Xcode项目配置文件。此更改涉及项目中所有的 文件路径和应用入口点(App/TijiApp.swift → App/KangkangApp.swift)。 ```
This commit is contained in:
99
康康/AI/AIRuntime.swift
Normal file
99
康康/AI/AIRuntime.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
import Foundation
|
||||
|
||||
enum AIRuntimeError: Error, LocalizedError {
|
||||
case notReady
|
||||
case modelLoadFailed(String)
|
||||
case inferenceFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notReady: return "AI 模型尚未准备好"
|
||||
case .modelLoadFailed(let m): return "模型加载失败:\(m)"
|
||||
case .inferenceFailed(let m): return "推理失败:\(m)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actor AIRuntime {
|
||||
static let shared = AIRuntime()
|
||||
|
||||
enum Status: Sendable, Equatable {
|
||||
case notReady
|
||||
case loading
|
||||
case ready
|
||||
case error(String)
|
||||
}
|
||||
|
||||
private(set) var status: Status = .notReady
|
||||
private(set) var lastDecodeRate: Double = 0
|
||||
|
||||
private var llmSession: LLMSession?
|
||||
|
||||
private init() {}
|
||||
|
||||
/// 加载模型。首次调用会真正加载,后续幂等。
|
||||
func prepare() async throws {
|
||||
switch status {
|
||||
case .ready:
|
||||
return
|
||||
case .loading:
|
||||
// 已有其他调用方在加载;本次 prepare 直接返回,
|
||||
// 调用方需稍后 await prepare() 再判 status,或自行轮询 / 显示加载 UI。
|
||||
// W3 引入 prepare 队列时优化。
|
||||
return
|
||||
case .error, .notReady:
|
||||
break
|
||||
}
|
||||
|
||||
guard ModelStore.shared.isReady(.llm) else {
|
||||
status = .error("LLM 模型未就绪")
|
||||
throw AIRuntimeError.notReady
|
||||
}
|
||||
|
||||
status = .loading
|
||||
do {
|
||||
let session = try await LLMSession.load(
|
||||
folderURL: ModelStore.shared.localURL(for: .llm)
|
||||
)
|
||||
self.llmSession = session
|
||||
status = .ready
|
||||
} catch {
|
||||
status = .error("\(error)")
|
||||
throw AIRuntimeError.modelLoadFailed("\(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式生成。调用前应先 await prepare()。
|
||||
/// 注意:返回流是同步创建的,但跨 actor 调用 LLMSession 需要 await。
|
||||
func generate(prompt: String, maxTokens: Int = 256) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||
// 在 actor 隔离上下文中捕获快照,Task 内不再访问 self.status / self.llmSession
|
||||
let snapshotStatus = status
|
||||
let snapshotSession = llmSession
|
||||
|
||||
return AsyncThrowingStream { continuation in
|
||||
Task {
|
||||
guard snapshotStatus == .ready, let session = snapshotSession else {
|
||||
continuation.finish(throwing: AIRuntimeError.notReady)
|
||||
return
|
||||
}
|
||||
do {
|
||||
// session.generate 跨 actor 边界,需要 await
|
||||
let stream = await session.generate(prompt: prompt, maxTokens: maxTokens)
|
||||
for try await chunk in stream {
|
||||
// Task 闭包在 generate() 内启动,继承 AIRuntime 的 actor 隔离;
|
||||
// 调用同 actor 的 recordRate 不需要 await
|
||||
self.recordRate(chunk.decodeRate)
|
||||
continuation.yield(chunk)
|
||||
}
|
||||
continuation.finish()
|
||||
} catch {
|
||||
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func recordRate(_ rate: Double) {
|
||||
if rate > 0 { lastDecodeRate = rate }
|
||||
}
|
||||
}
|
||||
87
康康/AI/LLMSession.swift
Normal file
87
康康/AI/LLMSession.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
import MLX
|
||||
import MLXLLM
|
||||
import MLXLMCommon
|
||||
|
||||
/// 封装 MLX 语言模型的流式生成,actor 保证单线程访问。
|
||||
/// 基于 mlx-swift-examples 2.29.1(commit 9bff95ca)的 API。
|
||||
actor LLMSession {
|
||||
let container: ModelContainer
|
||||
|
||||
init(container: ModelContainer) {
|
||||
self.container = container
|
||||
}
|
||||
|
||||
/// 从本地目录加载模型(包含 config.json + weights + tokenizer)。
|
||||
static func load(folderURL: URL) async throws -> LLMSession {
|
||||
#if targetEnvironment(simulator)
|
||||
// MLX 的 iOS Simulator GPU stream 初始化会在部分 Metal backend 路径中 abort。
|
||||
// 模拟器只用于功能调试,强制走 CPU;真机仍保留默认 GPU/ANE 相关路径。
|
||||
Device.setDefault(device: .cpu)
|
||||
#endif
|
||||
|
||||
let configuration = ModelConfiguration(directory: folderURL)
|
||||
let container = try await LLMModelFactory.shared.loadContainer(
|
||||
configuration: configuration
|
||||
)
|
||||
return LLMSession(container: container)
|
||||
}
|
||||
|
||||
/// 流式生成。返回的 AsyncThrowingStream 被取消时,内部 Task 也会取消。
|
||||
/// - Parameters:
|
||||
/// - prompt: 原始 prompt 文本(经 processor 转 LMInput)
|
||||
/// - maxTokens: 最大 token 数,由 GenerateParameters 控制
|
||||
func generate(prompt: String, maxTokens: Int) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
let task = Task {
|
||||
do {
|
||||
let parameters = GenerateParameters(
|
||||
maxTokens: maxTokens,
|
||||
temperature: Float(0.6),
|
||||
topP: Float(0.9)
|
||||
)
|
||||
|
||||
try await container.perform { (context: ModelContext) in
|
||||
let userInput = UserInput(prompt: prompt)
|
||||
let lmInput = try await context.processor.prepare(input: userInput)
|
||||
|
||||
let start = Date()
|
||||
var produced = 0
|
||||
|
||||
for await event in try MLXLMCommon.generate(
|
||||
input: lmInput,
|
||||
parameters: parameters,
|
||||
context: context
|
||||
) {
|
||||
if Task.isCancelled { break }
|
||||
|
||||
switch event {
|
||||
case .chunk(let text):
|
||||
produced += 1
|
||||
let elapsed = Date().timeIntervalSince(start)
|
||||
let rate = elapsed > 0 ? Double(produced) / elapsed : 0
|
||||
continuation.yield(TokenChunk(text: text, decodeRate: rate))
|
||||
|
||||
case .info:
|
||||
// 生成完成统计,是流的最后一个事件
|
||||
break
|
||||
|
||||
case .toolCall:
|
||||
// 纯文本生成不会触发,switch 穷举
|
||||
break
|
||||
}
|
||||
}
|
||||
// 注:研究笔记里曾建议尾部 MLX.GPU.synchronize() 以确保
|
||||
// GPU 操作全部完成。但 AsyncStream 已经 yield 真实解码后的
|
||||
// 文字,GPU 是否完全空闲不影响数据正确性。去掉此调用同时省
|
||||
// 一份 transitive import MLX 的依赖,简化 SPM 链接。
|
||||
}
|
||||
continuation.finish()
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
87
康康/AI/ModelStore.swift
Normal file
87
康康/AI/ModelStore.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
|
||||
enum ModelKind: String, CaseIterable {
|
||||
/// 与 HuggingFace mlx-community 仓库名一一对应,也是沙盒 Models/ 下的子目录名。
|
||||
case llm = "Qwen3-1.7B-4bit"
|
||||
case vl = "Qwen2.5-VL-3B-Instruct-4bit"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .llm: return "Qwen3-1.7B"
|
||||
case .vl: return "Qwen2.5-VL-3B"
|
||||
}
|
||||
}
|
||||
|
||||
/// HuggingFace 仓库 ID(org/name),用于下载
|
||||
var huggingFaceRepo: String { "mlx-community/\(rawValue)" }
|
||||
|
||||
/// 用于判定该模型是否已就绪的最小标志文件
|
||||
var sentinelFilename: String { "config.json" }
|
||||
}
|
||||
|
||||
/// `@unchecked Sendable`:rootURL 是 let,方法只读 filesystem(线程安全),
|
||||
/// 可被任意 actor / Task 跨边界访问。
|
||||
/// `nonisolated(unsafe) shared`:项目开启 `-default-isolation=MainActor` 后
|
||||
/// static 默认 MainActor 隔离,跨 actor 访问需要 await。这里手动 opt-out。
|
||||
final class ModelStore: @unchecked Sendable {
|
||||
nonisolated(unsafe) static let shared: ModelStore = {
|
||||
do {
|
||||
let appSupport = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let root = appSupport.appendingPathComponent("Models", isDirectory: true)
|
||||
return try ModelStore(rootURL: root)
|
||||
} catch {
|
||||
fatalError("ModelStore.shared init failed: \(error)")
|
||||
}
|
||||
}()
|
||||
|
||||
let rootURL: URL
|
||||
|
||||
init(rootURL: URL) throws {
|
||||
self.rootURL = rootURL
|
||||
try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func localURL(for kind: ModelKind) -> URL {
|
||||
rootURL.appendingPathComponent(kind.rawValue, isDirectory: true)
|
||||
}
|
||||
|
||||
func isReady(_ kind: ModelKind) -> Bool {
|
||||
let sentinel = localURL(for: kind).appendingPathComponent(kind.sentinelFilename)
|
||||
return FileManager.default.fileExists(atPath: sentinel.path)
|
||||
}
|
||||
|
||||
func totalBytes(for kind: ModelKind) -> Int {
|
||||
let folder = localURL(for: kind)
|
||||
guard let enumerator = FileManager.default.enumerator(
|
||||
at: folder,
|
||||
includingPropertiesForKeys: [.fileSizeKey]
|
||||
) else { return 0 }
|
||||
var sum = 0
|
||||
for case let url as URL in enumerator {
|
||||
if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
||||
sum += size
|
||||
}
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
/// Demo 现场旁路:从 Bundle 拷贝预装模型(W6 才真正使用,本周占位)
|
||||
func seedFromBundle(_ kind: ModelKind) throws {
|
||||
guard let bundleURL = Bundle.main.url(forResource: kind.rawValue, withExtension: nil) else {
|
||||
#if DEBUG
|
||||
assertionFailure("Bundle 缺少 \(kind.rawValue),检查资源是否加入 target")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
let target = localURL(for: kind)
|
||||
if FileManager.default.fileExists(atPath: target.path) {
|
||||
try FileManager.default.removeItem(at: target)
|
||||
}
|
||||
try FileManager.default.copyItem(at: bundleURL, to: target)
|
||||
}
|
||||
}
|
||||
6
康康/AI/TokenChunk.swift
Normal file
6
康康/AI/TokenChunk.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct TokenChunk: Sendable {
|
||||
let text: String
|
||||
let decodeRate: Double // tokens / second, 估算值
|
||||
}
|
||||
Reference in New Issue
Block a user