这个周末小宝终于没球赛了,我也不用开车来回奔波两小时,再在寒风中瑟瑟发抖两小时(赛前训练 比赛)看球。本来打算做个应用尝试结合语音和 chat completion 中的 tools 做个智能客服,结果rust下一个好用的openai sdk都没有,于是干脆心一横,周六边写边录了7个视频(前后大概 6-7 小时),也算是为了一碟醋,包了顿饺子。后来有朋友提醒可以用 async-openai(有 700 多 star),不过木已成舟,也就算了。编辑视频的时候看了看 async-openai 的代码,实现思路跟我类似,但很多处理的选择不那么好,比如 reqwest::Client
其实 Clone
起来非常轻量,但它大量使用带生命周期的 Client,增加没必要的复杂性。此外没有充分利用 reqwest 生态,不管是 retry 还是 multipart 的处理,都写了很多不必要的代码。
不管怎样,自己写一遍 OpenAI API 的 SDK,还是有很多收获的。首先,进一步理解了 OpenAI 的 API,也吐槽了一些 API 参数设计不合理的地方;其次,对 serde,尤其是 serde 对 enum 的各种场景的使用,有了更深刻的了解;最后,就是终于找到了最舒服的使用 chat completion with tools 的方法,比如我只需要为 tools 有关的代码使用特定的使用 JsonSchema 的数据结构:
代码语言:javascript复制#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize, JsonSchema)]
struct GetWeatherArgs {
/// The city to get the weather for.
pub city: String,
/// the unit
pub unit: TemperatureUnit,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, JsonSchema)]
enum TemperatureUnit {
/// Celsius
#[default]
Celsius,
/// Fahrenheit
Fahrenheit,
}
#[derive(Debug, Clone)]
struct GetWeatherResponse {
temperature: f32,
unit: TemperatureUnit,
}
// dummy function
fn get_weather_forecast(args: GetWeatherArgs) -> GetWeatherResponse {
match args.unit {
TemperatureUnit::Celsius => GetWeatherResponse {
temperature: 22.2,
unit: TemperatureUnit::Celsius,
},
TemperatureUnit::Fahrenheit => GetWeatherResponse {
temperature: 72.0,
unit: TemperatureUnit::Fahrenheit,
},
}
}
然后我就可以在 ChatCompletionRequest 中很简单地引用它:
代码语言:javascript复制let messages = vec![
ChatCompletionMessage::new_system("I can choose the right function for you.", ""),
ChatCompletionMessage::new_user("What is the weather like in Boston?", "user1"),
];
let tools = vec![
Tool::new_function::<GetWeatherArgs>(
"get_weather_forecast",
"Get the weather forecast for a city.",
),
Tool::new_function::<ExplainMoodArgs>(
"explain_mood",
"Explain the meaning of the given mood.",
),
];
let req = ChatCompletionRequest::new_with_tools(messages, tools);
let res = SDK.chat_completion(req).await?;
assert_eq!(res.model, ChatCompleteModel::Gpt3Turbo);
assert_eq!(res.object, "chat.completion");
assert_eq!(res.choices.len(), 1);
let choice = &res.choices[0];
assert_eq!(choice.finish_reason, FinishReason::ToolCalls);
assert_eq!(choice.index, 0);
assert_eq!(choice.message.content, None);
assert_eq!(choice.message.tool_calls.len(), 1);
let tool_call = &choice.message.tool_calls[0];
assert_eq!(tool_call.function.name, "get_weather_forecast");
get_weather_forecast(serde_json::from_str(&tool_call.function.arguments)?);
使用者不需要自己撰写复杂的关于参数的 json schema。
编写边录了大半天,最终写下了大概 1.2k 行 Rust 代码,录了7个视频:
视频这周每天都发一个,一周就把它发完。
饺子包完了,终于轮到那碟醋 —— 智能客服。周日一大早,我开了个新的项目,叫 ava-bot,也是边录边写。不过周日活动比较多,所以断断续续写了大概4-5小时,录了5个视频。第一个视频探讨了设计思路:
这个思路在实际执行时稍有偏差,比如 mpsc::Channel
最终换成了 broadcast::Channel
。这种通过 Channel 在两个路由间传输数据的方式还是很漂亮的:执行时间很长的 /assistant
路由不断把中间状态发送到 Channel 里,而使用支持 SSE(Server-Side Event)的路由 /chats
和 /signals
不断从 Channel 里拿数据,渲染成 HTML 后,以 SSE 发给客户端,客户端最终通过 HTMX SSE 组件自动进行更新。
这个实现我觉得最优美的地方是使用 enum template From trait helper function 使得冗长的数据结构到渲染 html 的过程变得非常简单清晰:
代码语言:javascript复制#[derive(Debug, Clone, Serialize, Deserialize, Template)]
#[template(path = "signal.html.j2")]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
enum AssistantEvent {
Processing(AssistantStep),
Finish(AssistantStep),
Error(String),
Complete,
}
#[derive(Debug, Clone, Serialize, Deserialize, EnumString, Display)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
enum AssistantStep {
UploadAudio,
Transcription,
ChatCompletion,
Speech,
}
impl From<AssistantEvent> for String {
fn from(event: AssistantEvent) -> Self {
event.render().unwrap()
}
}
// 一些 helper function
fn in_transcription() -> String {
AssistantEvent::Processing(AssistantStep::Transcription).into()
}
...
用于渲染 AssistantEvent
的 signal.html.j2
:
{% match self %}
{% when AssistantEvent::Processing with (v) %}
<p class="text-gray-800"><i class="fa-solid fa-spinner animate-spin"></i> Processing {{ v }}</p>
{% when AssistantEvent::Finish with (v) %}
<p class="text-green-800">Finished {{ v }}</p>
{% when AssistantEvent::Error with (v) %}
<p class="text-red-500"><i class="fa-solid fa-circle-exclamation"></i>Error: {{ v }}</p>
{% when AssistantEvent::Complete %}
<p class="text-green-800"><i class="fa-solid fa-check"></i>Complete</p>
{% else %}
<p class="text-yellow-700">Unknown event</p>
{% endmatch %}
这使得在 /assitant
路由中发送状态的代码变得非常优美:
signal_sender.send(in_transcription())?;
let input = transcript(llm, data.to_vec()).await?;
signal_sender.send(in_chat_completion())?;
let output = chat_completion(llm, &input).await?;
signal_sender.send(in_speech())?;
let audio_url = speech(llm, device_id, &output).await?;
signal_sender.send(complete())?;
由于太久不写 javascript,在录制的过程中,当我使用 MediaRecorder 时,按照 copilot 给出的代码(MDN 也是类似),我总遇到获取 audio data 出错的问题,大家可以看看下面的代码,想想为何:
代码语言:javascript复制let recorder = {
mediaRecorder: null,
recordedChunks: [],
init: function () {
// Request access to the microphone
navigator.mediaDevices.getUserMedia({ audio: true })
.then(stream => {
this.mediaRecorder = new MediaRecorder(stream);
console.log(this.mediaRecorder);
this.mediaRecorder.ondataavailable = function(e) {
// 这里 e.data 报错:read property 'push' of undefined
this.recordedChunks.push(e.data);
};
...
}
}
一开始我以为是音频设备冲突的原因,于是停止了录制,结果还是出错。后来经过一番 debug,发现是作用域的问题,这句话应该写成:
代码语言:javascript复制this.mediaRecorder.ondataavailable = (e) => { ... }
最终,周日并未完成 ava-bot 的全部功能,只写了四百多行 Rust 代码: