Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src-tauri/src/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@
//! 通不通,走降级链会让备用服务的成功掩盖主用服务的故障。理由写在那边的代码里。
//!
//! 内置提示词统一放在 [`prompts`],用户可编辑的打招呼与回复提示词仍在配置文件里。
//!
//! [`trace`] 在这条唯一入口上旁路记录每轮的提示词、原始输出与判定,供测试模式调提示词用——
//! 日志为了不泄露简历原文只留原因和长度,缺的正是调提示词最需要的那部分。
//! 代价是这些内容确实敏感,所以轨迹只驻内存、随进程退出即丢,除非用户显式导出,否则不落盘。

pub mod output;
pub mod prompts;
pub mod run;
pub mod tasks;
pub mod trace;

pub use run::{run, AgentOutcome, AgentRunner, AgentStop, AgentTask};
248 changes: 234 additions & 14 deletions src-tauri/src/agent/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@
//! 这里把那条链路收成一处,调用方只需要描述任务本身:给什么上下文、
//! 期望什么结构、什么样的结果算合格。装参数、渲染、重试、净化、校验由循环负责。

use std::time::Instant;

use serde_json::Value;

use crate::agent::output;
use crate::agent::trace::{self, AgentTrace, RoundTrace, RoundVerdict};
use crate::config::AppRuntimeConfig;
use crate::error::AppError;
use crate::llm::service::LlmChainService;
Expand All @@ -34,6 +37,17 @@ pub enum AgentStop {
Recovered,
}

impl AgentStop {
/// 轨迹里存字符串而不是直接序列化枚举:这个值要送到前端展示,
/// 存成稳定的小写标识后,以后给枚举加分支也不会改掉已有轨迹的字面量
fn trace_label(self) -> &'static str {
match self {
Self::FirstTry => "first_try",
Self::Recovered => "recovered",
}
}
}

/// 一次运行的完整结果。
#[derive(Debug, Clone)]
pub struct AgentOutcome<T> {
Expand All @@ -45,6 +59,8 @@ pub struct AgentOutcome<T> {
pub raw: String,
/// 被否掉的轮次理由,按顺序。为空表示一次过
pub rejections: Vec<String>,
/// 本次运行在轨迹缓冲里的 id,供测试模式关联展示
pub trace_id: String,
}

/// 一个可执行的 Agent 任务。
Expand Down Expand Up @@ -101,13 +117,15 @@ pub trait AgentTask {
pub struct AgentRunner<'a> {
config: &'a AppRuntimeConfig,
cancel: Option<Box<dyn Fn() -> bool + Send + Sync + 'a>>,
trace_id: Option<String>,
}

impl<'a> AgentRunner<'a> {
pub fn new(config: &'a AppRuntimeConfig) -> Self {
Self {
config,
cancel: None,
trace_id: None,
}
}

Expand All @@ -117,6 +135,17 @@ impl<'a> AgentRunner<'a> {
self
}

/// 指定这次运行的轨迹 id,让调用方在运行之前就拿到它。
///
/// 失败时 `execute` 只能返回 [`AppError`],`AgentOutcome` 连同里面的 `trace_id`
/// 一起没有了——而失败恰恰是调提示词时最需要翻轨迹的时候。调用方靠「取最新一条」
/// 反查在并发跑任务时会认错人,所以把 id 的分配权交给它:
/// 先 [`trace::next_id`] 拿号,再传进来,成功失败都能对上。
pub fn with_trace_id(mut self, id: impl Into<String>) -> Self {
self.trace_id = Some(id.into());
self
}

fn cancelled(&self) -> bool {
self.cancel.as_ref().is_some_and(|check| check())
}
Expand Down Expand Up @@ -152,8 +181,50 @@ impl<'a> AgentRunner<'a> {
T: AgentTask,
F: FnMut(String) -> Result<(), AppError>,
{
let service = LlmChainService::from_runtime(self.config)?;
let base_prompt = task.build_prompt()?;
// 埋点在这里而不是各个调用方:这是全部八种模型用途唯一的出口,
// 埋一处就等于全覆盖,也不会有新用途忘了接
let trace_id = self
.trace_id
.clone()
.unwrap_or_else(trace::next_id);
let started_at = chrono::Local::now().to_rfc3339();
let started = Instant::now();
let mut trace_rounds: Vec<RoundTrace> = Vec::new();

// 准备阶段的两次失败也要留痕,哪怕一轮都还没跑起来。
// 模板漏填变量正是这个项目踩过最深的坑(猎聘自动回复整个平台静默失效,
// 见本文件末尾的回归测试),而它的报错发生在这里、轨迹却什么都没有的话,
// 测试模式页面就会在最该看清的地方一片空白
let service = match LlmChainService::from_runtime(self.config) {
Ok(service) => service,
Err(error) => {
record_trace(
&trace_id,
task.name(),
&started_at,
started,
trace_rounds,
None,
Some(error.message.clone()),
);
return Err(error);
}
};
let base_prompt = match task.build_prompt() {
Ok(prompt) => prompt,
Err(error) => {
record_trace(
&trace_id,
task.name(),
&started_at,
started,
trace_rounds,
None,
Some(error.message.clone()),
);
return Err(error);
}
};

let streaming = on_delta.is_some();
let max_rounds = if streaming {
Expand All @@ -165,7 +236,17 @@ impl<'a> AgentRunner<'a> {

for round in 1..=max_rounds {
if self.cancelled() {
return Err(AppError::cancelled("任务已停止,大模型调用已取消"));
let error = AppError::cancelled("任务已停止,大模型调用已取消");
record_trace(
&trace_id,
task.name(),
&started_at,
started,
trace_rounds,
None,
Some(error.message.clone()),
);
return Err(error);
}

let prompt = match rejections.last() {
Expand All @@ -175,36 +256,115 @@ impl<'a> AgentRunner<'a> {

// 两条路都是流式:流式避开了网关掐断静默长连接、整体超时把已生成内容
// 全部作废这两个坑。区别只在增量推不推出去——不推才敢重试和降级
let response = match on_delta.as_mut() {
Some(callback) => service.stream_with(prompt, callback).await?,
None => service.stream_collect(prompt).await?,
let call_started = Instant::now();
let called = match on_delta.as_mut() {
Some(callback) => service.stream_with(prompt.clone(), callback).await,
None => service.stream_collect(prompt.clone()).await,
};
let call_ms = elapsed_ms(call_started);

// 原先这里是直接 `?` 抛出的,失败的那一轮什么都留不下——
// 而「调用压根没成功」恰恰是调提示词时最需要区分的一种情况,
// 所以先把这一轮连同整条轨迹落进缓冲,再原样把错误抛出去
let response = match called {
Ok(response) => response,
Err(error) => {
// 只记 message:detail 里可能带上游返回体,含鉴权信息
trace_rounds.push(RoundTrace {
round,
prompt,
raw: String::new(),
model: None,
usage: None,
duration_ms: call_ms,
verdict: RoundVerdict::Failed {
reason: error.message.clone(),
},
});
record_trace(
&trace_id,
task.name(),
&started_at,
started,
trace_rounds,
None,
Some(error.message.clone()),
);
return Err(error);
}
};

if self.cancelled() {
return Err(AppError::cancelled("任务已停止,大模型调用已取消"));
// 这一轮拿到了输出却没走到解析,三种 verdict 没有一个描述得准,
// 与其把 Rejected 的语义弄脏,不如让它缺席,由整条轨迹的 error 说明
let error = AppError::cancelled("任务已停止,大模型调用已取消");
record_trace(
&trace_id,
task.name(),
&started_at,
started,
trace_rounds,
None,
Some(format!("{}(末轮输出未参与解析)", error.message)),
);
return Err(error);
}

let raw = output::sanitize(&response.content);
let model = response.model.clone();
let usage = response.usage.clone();
let rejection = match task.parse(&raw) {
Err(reason) => reason,
Ok(parsed) => match task.validate(&parsed) {
Ok(()) => {
let stop = if rejections.is_empty() {
AgentStop::FirstTry
} else {
AgentStop::Recovered
};
trace_rounds.push(RoundTrace {
round,
prompt,
raw: raw.clone(),
model,
usage,
duration_ms: call_ms,
verdict: RoundVerdict::Passed,
});
record_trace(
&trace_id,
task.name(),
&started_at,
started,
trace_rounds,
Some(stop.trace_label()),
None,
);
return Ok(AgentOutcome {
output: parsed,
stop: if rejections.is_empty() {
AgentStop::FirstTry
} else {
AgentStop::Recovered
},
stop,
rounds: round,
raw,
rejections,
trace_id,
});
}
Err(reason) => reason,
},
};

trace_rounds.push(RoundTrace {
round,
prompt,
raw: raw.clone(),
model,
usage,
duration_ms: call_ms,
verdict: RoundVerdict::Rejected {
reason: rejection.clone(),
},
});

// 内容本身可能含求职者隐私,日志只留原因和长度
let _ = logger::warning(format!(
"Agent「{}」第 {}/{} 轮输出未通过校验({} 字):{}",
Expand All @@ -217,15 +377,54 @@ impl<'a> AgentRunner<'a> {
rejections.push(rejection);
}

Err(AppError::provider(format!(
let error = AppError::provider(format!(
"Agent「{}」连续 {} 轮输出都不合格:{}",
task.name(),
max_rounds,
rejections.last().map(String::as_str).unwrap_or("原因未知")
)))
));
record_trace(
&trace_id,
task.name(),
&started_at,
started,
trace_rounds,
None,
Some(error.message.clone()),
);
Err(error)
}
}

fn elapsed_ms(since: Instant) -> u64 {
u64::try_from(since.elapsed().as_millis()).unwrap_or(u64::MAX)
}

/// 把一次运行落进内存缓冲。
///
/// 每条退出路径上各调一次,而不是用 Drop 守卫自动兜底:守卫只知道「函数结束了」,
/// 拿不到「成功还是失败、失败在哪一步」这些只有退出点才清楚的信息,
/// 而这恰恰是测试模式要看的东西。代价是新增退出路径时得记得补一行。
fn record_trace(
id: &str,
task_name: &str,
started_at: &str,
started: Instant,
rounds: Vec<RoundTrace>,
stop: Option<&str>,
error: Option<String>,
) {
trace::record(AgentTrace {
id: id.to_string(),
task_name: task_name.to_string(),
started_at: started_at.to_string(),
duration_ms: elapsed_ms(started),
rounds,
stop: stop.map(str::to_string),
error,
});
}

/// 不需要取消检查时的快捷入口
pub async fn run<T: AgentTask>(
task: &T,
Expand Down Expand Up @@ -313,4 +512,25 @@ mod tests {

assert_eq!(task.max_rounds(), 2);
}

/// 「返工一次才通过」的完整轨迹(第 1 轮 Rejected、第 2 轮 Passed)这里测不了:
/// 走完 `execute` 至少要两次真实的模型往返,而这组测试的前提就是不发网络请求,
/// 为了可测把循环拆成可注入的假 service,等于为测试重塑生产结构,代价大于收益。
/// 因此这里只守住埋点的形状(每条退出路径都带上 trace_id),
/// 缓冲本身的行为由 `agent::trace` 的单元测试覆盖,两轮串起来的效果靠集成验证。
#[test]
fn outcome_carries_a_trace_id_for_the_test_mode_page() {
let outcome = AgentOutcome {
output: "结果".to_string(),
stop: AgentStop::Recovered,
rounds: 2,
raw: "结果".to_string(),
rejections: vec!["输出不是合法 JSON".to_string()],
trace_id: "trace-7".to_string(),
};

assert_eq!(outcome.trace_id, "trace-7");
assert_eq!(AgentStop::FirstTry.trace_label(), "first_try");
assert_eq!(AgentStop::Recovered.trace_label(), "recovered");
}
}
Loading
Loading