☰
没有 Shell、没有铸造凭据:freehire 如何在 API 进程内运行有界 Tool-Calling Agent 循环
2026/10/22 17:49:14 网站建设 项目流程

【免费下载链接】freehire

freehire — the open-source search engine for job seekers

项目地址:https://gitcode.com/GitHub_Trending/fr/freehire
点击查看免费下载

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.goAgent 循环、事件形状、边界与持久化
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

项目地址:https://gitcode.com/GitHub_Trending/fr/freehire
点击查看免费下载
上一篇:为什么Q8量化可能改变说话人边界?Nemotron-3-Diarization-GGUF输出一致性风险与应对清单
下一篇:让AI写中文海报翻车记:复旦MOSS-Image-SFT三大坑,我替你踩了个遍

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询