【免费下载链接】freehire
freehire — the open-source search engine for job seekers
freehire 是一个开源的求职搜索引擎,它把 AI Agent 直接跑在自己的 API 进程里:模型想调用工具时,工具拿到的不是 Shell,也不是临时铸造的令牌(credential),而是会话属主的userID,随后直接调用同一个 Go 服务。这种有界(bounded)的 Tool-Calling Agent 循环,最多跑 8 轮(无人值守的 CV 定制跑 30 轮),每一步都有持久化、有取消、有明确终止事件,是新手理解"LLM Agent 安全落地"的好样本。
先认识 freehire:不止是工作搜索引擎
freehire 聚合了 300 万+条直接抓取自公司 ATS(Workday、Greenhouse、Lever、Ashby 等 92 个平台)的在招职位,并提供分面搜索、CV 定制、申请追踪等功能。它的技术栈是 Go + Fiber 的 HTTP 服务,加上 PostgreSQL、Meilisearch 和任意 OpenAI 兼容的 LLM 端点(通过 Bifrost 网关做按人计费)。
除了"找工作",freehire 还内置了一个应用内 AI 助手(in-app assistant),支持聊天、CV 定制、档案问答、面试演练等 5 种预设。今天的主角就是这个助手背后的 Agent 循环,核心代码全部位于 internal/ai/assistant/ 包中。
为什么 Agent 要跑在 API 进程里?
常见的 Agent 部署方式有两种:外部独立运行时(单独起个容器跑 Agent),或给模型发一个可操作的凭据让它自由发挥。freehire 在 assistant 的 AGENTS.md 里把设计原则写得非常直白:
这个 Agent 运行在本进程内。没有外部运行时、没有 Shell、没有凭据——工具拿到的是会话属主的
userID,然后直接调用 Go 服务。工具不该到达的东西,就是一个不存在的工具。
这句话是理解整个架构的钥匙。
工具拿到的是 userID,不是令牌
看 tool.go 中的工具契约:
type Tool struct { Name string Description string Schema map[string]any Run func(ctx context.Context, userID int64, args json.RawMessage) (any, error) }注意Run的签名:它接收userID int64,而不是任何 API token。这意味着:
- 没有需要"铸造"(mint)的凭据:不用为每轮对话签发短期令牌,也就没有令牌泄漏、续期、撤销这一整套问题;
- 权限收敛在数据库查询里:工具内部调用的服务和 HTTP 接口用的是同一套服务代码,归属校验就是一句
WHERE user_id = $1——不属于自己的会话,直接按"不存在"(404)处理; - 越权路径物理上不存在:模型连"用谁的权限访问"都决定不了,身份在循环启动时就被绑定死了。
不该存在的工具,干脆不注册
工具的注册发生在 internal/api/handler/assistant_tools.go:每个会话按其预设(preset)组装工具集。比如 CV 定制会话的cv_edit工具直接闭包绑定该会话的 CV id 和职位 id,模型就算猜中别的 CV id 也写不进去;邮件工具没有"发送"能力——因为邮件正文是攻击者可控文本,最稳的防提示注入办法就是让注入"没有出站通道"。
有界 Agent 循环:8 轮上限是怎么定的
Agent 循环的核心是 runner.go 中的Runner.Run,流程可以概括为:
POST /api/v1/assistant/sessions/:id/messages └─ handler:属主校验 → 按预设取注册表 → SSE 流 └─ Runner.Run ├─ 持久化用户消息,发射 user_prompt 事件 ├─ 重建历史 = 系统提示 + 最近 N 条转录 └─ 循环(≤ MaxSteps): llm.Chat(历史, 工具) ──► 纯文本?→ 发 result(end_turn),结束 └─► 工具调用?→ 逐个执行、发射 tool_use/tool_result、持久化 └─ 触顶 → 最后一次无工具的 Chat → result(max_steps)循环的每一步都被持久化
循环里产生的每一帧(用户消息、模型文本、工具调用、工具结果)都边生成边写入转录表。因此一个中途死掉的回合仍留下可续跑的会话;重试走Runner.Continue,它不会重复追加用户消息(那会在模型上下文里复制一份请求),只会从现有转录续跑。
上限不是"无限",而是"默认值"
NewRunner 对MaxSteps的处理很能说明工程态度:零值或负值会回退到默认值 8 轮,而不是解释为"不设限"。注释里写得很直白:"在按量计费的网关上跑一个不设限的 Agent 循环,就是一张失控的账单。"而单回合的 LLM 调用超时是第二道边界。需要说明的是,这些上限只能由服务端选择——客户端能调高的上限,就不叫上限。
不同场景的上限也不同:普通提问 8 轮、交互式 CV 定制 16 轮、无人值守的 autopilot 定制 30 轮(一次遍历职位的全部要求)。触顶后循环会再发最后一次不带工具的 Chat,逼模型给出总结——即使某些供应商(如 Bedrock 上的 Haiku)此时仍返回工具调用,代码也会先执行完它们再停止,保证转录永远不会以一个悬空的tool_use结尾。
工具失败 ≠ 回合失败
这是新手最容易忽略的设计。Registry.Call从不返回 Go error:未知工具、参数格式错误、服务调用失败,统统包装成{"error": "..."}的 JSON 交还给模型阅读。错误信息会指名"哪个值非法、合法值有哪些"——因为这条消息是模型自我纠正的唯一路径。只有模型/传输层本身的失败才会让整个回合以error结束。
配合 DecodeArgs 的严格解码(拒绝未知字段、拒绝尾部多余内容),一次写错的工具调用会得到"可行动的失败",而不是带着半个参数悄悄执行。
每个回合都终结于恰好一个 result 事件
如果客户端收到一条"悄悄停掉"的流,它会永远等下去。所以 runner.go 定义了四种终止原因(end_turn/max_steps/cancelled/error),并保证所有退出路径——包括循环开始前就死掉的写库失败——都发射一个result事件。取消(用户点 Stop)是一个独立的POST /assistant/sessions/:id/cancel请求,因为"连接断了"和"用户主动停止"无法区分;而一次回合还会活过它的读者:SSE 写失败只说明这个读者不再听了,回合继续跑完、转录照常落库。
Preset:一份提示词 + 一组工具
会话的预设(chat、tailor、profile、browse、interview、debrief)只决定两件事:系统提示词和注册的工具集。词汇表由数据库assistant_sessions.preset列的 CHECK 约束钉死,所以新增一个预设是模式变更,而不仅仅是一个 Go 常量。
预设的提示词由 SystemPrompt(preset, language) 生成,且每份系统提示都携带一条 LANGUAGE 指令——按候选人档案里保存的语言作答,而不是猜他这条消息用什么语言打的字。同一个 chat 组件因此能同时服务网页端的/my/assistant、CV 定制工作区、面试演练和浏览器扩展的侧边栏。
转录即历史:一张表,两个用途
message.go 里的Message(seq+role+ JSON 载荷)同时是客户端回放的数据和重建模型历史的数据。两个用途共用一张表带来的约束很值得学习:
- 模型的原始参数字符串原样存储(只做一处修复:剥掉 Haiku 在合法 JSON 后追加的
</invoke>尾巴),因为重新编码会改变模型看到的字节; - 历史窗口按"块"滑动而不是每回合滑一条,让前缀在块内保持逐字节一致,从而命中供应商的提示词缓存——生产上曾测量到每请求 3 万 token 几乎全部按全价重读,修好块对齐后成本大幅下降(见 runner.go 中 historyBlock 的注释);
- 重建历史时会丢弃窗口开头"孤立的 tool_result",并为"悬空的 tool_use"合成一条错误结果——因为 Bedrock 等供应商会拒绝整条不合法的请求,孤儿消息会把"丢上下文"升级成"整个回合失败"。
花费可控:按人计费 + 按天配额
Agent 每回合都走调用者自己的网关凭据(由 internal/ai/llmkey/ 在其首次 AI 调用时铸造),Runner 在启动时构建一次、按回合克隆(Runner.With),因为凭据是按人的,而边界不是。每次调用带feature:assistant和preset:<preset>两个标签,网关按标签记一行消费——这样一次面试演练和一次无人值守定制的花费就可比较了。
真正"拒绝"一个回合的是 internal/ai/plan/ 的配额:按功能、按 UTC 天计额的允许量,超额在流打开前就返回 HTTP 402。而网关侧的账户级预算(LLM_USER_MAX_BUDGET)故意留空——在消费分布未知前设定的天花板只是猜测;配额是给产品定价的,网关预算才是保险丝。
关键文件索引
| 文件 | 作用 |
|---|---|
| internal/ai/assistant/runner.go | Agent 循环、事件形状、边界与持久化 |
| internal/ai/assistant/tool.go | 工具契约、注册表、严格参数解码 |
| internal/ai/assistant/message.go | 转录消息编码与往返 |
| internal/ai/assistant/prompt.go | 各预设的系统提示词 |
| internal/api/handler/assistant_tools.go | 按预设组装工具注册表 |
| internal/ai/assistant/AGENTS.md | 该模块最完整的设计说明,强烈推荐细读 |
小结
freehire 的助手 Agent 证明了:把 LLM Agent 放进 API 进程内,并不需要牺牲安全性去换便利。它的三板斧是——身份用 userID 绑定而非凭据、权限用"工具不存在"实现而非运行时检查、循环用步数与超时双重封顶并全程持久化。对于想给自己的 Go 服务加 AI Agent 的开发者来说,这套约一千行的assistant包,是一个可以直接对照着学的有界 Tool-Calling 循环参考实现。
【免费下载链接】freehire
freehire — the open-source search engine for job seekers
相关推荐
开源职位搜索引擎 freehire 深度解析:330万条在招职位直连企业 ATS,没有招聘中介、没有重发、没有死链
开源职位搜索引擎 freehire 深度解析:330万条在招职位直连企业 ATS,没有招聘中介、没有重发、没有死链 freehire 是一个 开源职位搜索引擎
JavaScript 教程实战:for 循环中 `i++` 与 `++i` 到底有没有区别?
JavaScript 教程实战:for 循环中 i++ 与 ++i 到底有没有区别? 本篇基于现代 JavaScript 教程(zh.javascript.in
文档教程Jev 到底有没有被高估
Jev 到底有没有被高估 2026 年 9 月 15 日,前 OpenAI InstructGPT 核心作者 Diogo Almeida 创立的 TypeSaf
AI 应用大模型交互助手RAG
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考