Centrol 代码深度分析:Agent 怎么搭的、哪里是毛线团、业务流程与代码流程对照
调研日期:2026-11-05。对象:repos/CentrolAImain 分支(579 次提交,最后提交 2026-11-05,主要作者 Gloridust)。
起因:合伙人在 Lark 上的八轮反馈(Feedback 1.0 → 8.0,2026-08 至 2026-11-04)里,大量问题反复出现、改了又回来;开发方自己的评价是「不够智能、不够灵活、很难跑通」。
方法:先读仓库自己的架构文档与 37 份 ADR、46 份踩坑笔记,再分五路只读通读源码(Agent 内核、旧lib/ai层、数据层与状态机、九步业务流程逐步追代码、Funyard 工程做法对照),每条结论都对到文件和行号。文档与代码不一致时以代码为准。
读者:我们自己(决定要不要在这套代码上继续做)和合伙人(理解为什么反馈改不动)。
0. 一页结论
这个 Agent 的「形状」是对的,但「骨架」没有。 2026-10-16 的重构(ADR 0026)把对话 Agent 做成了常驻、一步一落库、能被事件叫醒的会话,这一步解决了「Agent 只活 95 秒」的根本问题,方向和业界(Anthropic Managed Agents、pi)一致。但它没有替换旧层,而是在旧层上加了一层「名字翻译 + 权限判定」:真正的业务逻辑(匹配 977 行一个函数、梯子 985 行一个函数、工具执行 31 个 case 的 switch、SMTP 投递 607 行)仍留在 2026-08 起堆出来的三个巨型文件里。
「毛线团」不是风格问题,是七件结构性的事叠在一起:
| # | 根因 | 一句话证据 |
|---|---|---|
| 1 | 没有领域层,没有状态机 | 创作者 13 态在 10 个文件 33 处裸写;两张核心表各 44–45 列,状态、子状态标记、缓存混在一行;「在哪一步」每拍从 ≥10 列 + 3 张表重新推导 |
| 2 | 新内核叠在旧层上,三层工具体系 | 模型看 toolbox-spec(29 个)→ toolbox 翻译(13 个自写、14 个转发)→ 旧 runTool(31 case);旧 tools.ts 949 行只剩 schema 被借用;新旧两层用 await import 绕循环依赖 |
| 3 | 横切关注点没有落点 | 权限 decide() 散在 12 个文件、事件写入 90 处/17 文件、扣费 65 处/15 文件;代码注释自承「七个地方要记得写,漏一个不报错」 |
| 4 | 同一件事多份实现 | 金额提取 5 份、排信入口 8 处、成交 3 条路、挑人 3 套、确认卡 4 套 key/payload/执行路径、审批双轨、重试黑名单两套、梯子两套编号 |
| 5 | 队列表和事件表身兼数职 | agent_runs 同时是队列、锁、进度表;claim_next_run 整函数重写 8 次、WHERE 11 个条件;确认卡开/关靠扫 thread_messages,9 处各扫一遍、limit 各异 |
| 6 | 测试钉的是源码文本,不是行为 | 94 个测试文件里 88 个用 readFileSync 读 src/,3,009 条 assert.match 匹配源码正则;没有 CI;按客户反馈轮次命名(test-feedback5.mjs…)。任何重构都让几十个测试变红 |
| 7 | 开发模式是「按反馈逐条打补丁」 | 151/579 次提交碰 ≥10 个文件;每轮审查 40–90 条问题;每条反馈同时动工具、梯子、提示词三处;runner.ts 73 个、advance.ts 56 个 ⚠️ 事故注释 |
对我们的含义: 它不是一个能在上面「继续加功能」的代码库,而是一份非常详细的、用血换来的需求与坑位清单(37 份 ADR + 46 份踩坑笔记 + 37 份评测场景)。真正值钱的是这些知识和几块做对了的部件(见 §6),不是这 8.8 万行 TypeScript 本身。
1. 这个 Agent 是怎么搭的
1.1 四个「大脑」与它们的分工
文档里说「一个常驻的活动 Agent」,代码里实际并存四个决策者,互相靠事件和数据库列沟通:
| 决策者 | 在哪 | 谁触发 | 决定什么 | 用什么和别人沟通 |
|---|---|---|---|---|
| 常驻活动 Agent(新) | lib/agent/loop.ts | 用户说话(turn)、事件叫醒(wake)、点卡后续跑(approval) | 建活动、改设定、挑人、给谈判下指令、回答用户 | 工具调用;给梯子设参数(hold_until / keep / selection_notes);事件日志 |
| 驱动器梯子(旧) | lib/ai/advance.ts | cron 每 120s 心跳(空转 600s)+ 每个任务跑完 chainAdvance | 放行积压、发外联、挑人、追信、放手、补人 | 读 Agent 设的列;出确认卡(propose.ts);例外时 requestWake 叫醒 Agent(宽限 30 分钟) |
| 谈判子 Agent(旧) | lib/ai/agents/negotiate.ts + runner.runNegotiate | 每封来信一个 negotiate 任务 | 回信措辞、成交/回绝/升级判定 | 读 Agent 写的 directive;写事件 escalated(叫醒)/ deal_closed(不叫醒) |
| 13 种单次任务 | lib/ai/runner.ts HANDLERS | 队列 | 匹配、写外联、审片、复盘、摘要、解析文件、拉网盘链接… | 写表 + 写事件 |
ADR 0026 承诺的「谈判 → 主 Agent 的结构化报告 negotiation_report」在代码里不存在,替代物是事件旁路:只有升级、匹配完成、审片完成会叫醒主 Agent,成交和回绝不叫醒,主 Agent 要等下一次用户说话时才从事件日志里读到。
1.2 一轮对话(turn)端到端
浏览器 POST /api/copilot app/api/copilot/route.ts
├ requireOrg(余额 ≤ 0 直接 402)→ 用户消息先落库 append_thread_message
├ beginUserTurn:insert agent_runs{kind:turn, status:running} lib/agent/store.ts:234
│ 锁 = 迁移 0061 的部分唯一索引 agent_runs_one_live_turn(一个对话同时只有一轮)
│ 插不进去 → 接手排着的那行 / 回 {queued:true}
├ ReadableStream(ndjson) + runTurn({inline:true, deadline:+95s}) lib/agent/loop.ts:112
└ after():compactCopilotThread 折叠长对话进 context_digest
runTurn 主循环,每圈:
① 查 threads.agent_cancel_at(真的能停)
② 上一步没执行完的工具 → executeCall
③ 拼上下文:loadLiveState(实时状态块)+ loadLog(thread_messages)+ 最近 6 轮 agent_steps
→ projectLog(按模型「当时看到的顺序」重排;补 user 结尾)
system = agentSystemPrompt(≤4k)+ context_digest,<now> 贴在最后一条消息末尾
④ streamWithTools(lib/ai/client.ts:888)边流边推给浏览器
⑤ appendStep(role:assistant, tool_calls) → 回 ①
executeCall(loop.ts:400):
effectOf(tool)(read / external / config / money / delete …)
→ userSaidIt 正则 → gateRule(效果 × 来源 × 是否读过外部文字)→ run / ask / classify
→ classify 时调一个只看用户原话的分类器
→ ask 时 postCard(approval_requested 事件)
→ run 时 executeAgentTool(lib/agent/toolbox.ts)→ 多数转 runTool(lib/ai/tool-exec.ts)
部分直调 lib/data/actions,部分直接 .from() 写表
→ appendStep(role:tool)
收尾:appendMessage(assistant) + chargeFeature(幂等键 turn:<run>)+ finishRun
超时:handoffRun(status→queued,attempts 清零)→ cron 认领 → runner HANDLERS.turn
→ withSystemActor(无用户会话时用「系统执行身份」)→ runTurn(inline:false, 240s)
已有结果的 tool_call_id 不重做
做对的部分:一步一落库、唯一索引当锁、tool_call_id 去重、requeueRun 合并排队行,这四件事真正解决了「95 秒」问题。gate-rules / consent-rules / projection / wake-rules 是纯函数,能裸 node 测。withSystemActor 让被叫醒的 Agent 和人点按钮走同一组函数,没有另写一套 worker 版。
但 loop.ts 也承担了本该在协议层或网关层解决的「模型行为矫正」:按书写系统压流(中文里闪英文)、整段回复再调一次模型翻译(fixReplyScript)、模型说了要做没做就替它排任务(keepBriefPromise)、叫醒时和上一条没答的问题两字一组重合 ≥55% 就删掉(dropRepeatedQuestion)。主循环 56 个 if、12 个出口;lib/agent/ 里带 2026-10 日期的事故注释 109 处,这个目录 3 天内 31 次提交。
1.3 被叫醒(wake)与驱动器梯子
- wake 怎么来:
appendThreadEvent(..., {wake})→requestWake:插一行agent_runs{kind:wake, queued, next_attempt_at +60s}去抖;撞唯一索引就把原因并进排着的那行。闹钟走agent_timers→fireDueTimers。 - 限额与计费:
decideWake依次查总开关 → 对话在不在 → 暂停 → 每日上限(默认 24)→ 余额;入队判一次、认领再判一次。wake 扣agent_wake,turn 扣copilot_reply。 - 梯子:
advanceCampaign一个函数 985 行、61 个 if、16 个提前 return,档位 ⓪ 起草 brief → ① 放行待批 → ② 草稿入队 → ③ 挑人 → ④ 来信没回 → ⑤ 追信/放手 → ⑥ 补人 → ⑦ 没事干。每拍从loadLadderRows重读,「在哪一步」由status + outreach_queued_at + defection_reason + nudge_count + hold_until + keep + autopilot_blocked_at + pending_actions + thread_messages 里的卡推导。decide()在主梯子里调 9 次,其中一处「deny 之后再判一次 auto」。返回值是 23 种自由字符串。同一文件尾部的分段注释和头部编号对不上(两套编号)。 - 判断:这是一个「每两分钟跑一遍的巡检脚本」,不是状态机。没有状态枚举、没有转移表;每档内部按 auto / ask 两条路各写一遍。
1.4 谈判子 Agent
文档说「四道闸:detectEscalation → complete → screenMessages → decide」。代码实际是:入口升级检测(仅非跟进、非人工改稿)→ 最多 2 轮工具查资料 → 生成 → WAIT 判定 → 截断判定 → 风格闸 → 繁简转换 → 单号/人填价核对 → agreedFee 分支(内含 decide)→ maxOffer 分支 → 跟进分支(内含 decide)→ 主 decide。blocked() 17 处、decide() 3 处分散在三条分支里。negotiation-context.ts 拼输入时顺手写库(更新 quote、first_ask)。
护栏做对了:价格上限、让步次数卡在 policy.ts 判定表和出参校验里,不指望模型遵守;「只有 SMTP 成功才算寄出」;回信按人设工作时段排 deliver_at。
1.5 队列、邮件、计费
- 队列
agent_runs:14 种 kind、5 种 status;claim_next_run最终版约 70 行,WHERE 里 11 个条件(queued、退避、对话已删、活动已删、campaign 沙盒、thread 沙盒、org 挂起、org_balance()>0每行调一次聚合、活动暂停的 5 种 kind、同 chat_session 已有 negotiate、同 thread 已有 turn/wake),再for update skip locked+ 捕获 unique_violation 兜底。这个函数被整体重写 8 次(0011/0023/0027/0035/0051/0060/0061/0062)。插入agent_runs的代码散在 10 个文件 27 处。 - 邮件:出站只有
runner.deliverDueMessages(607 行,放在 Agent 队列消费者文件里)真正调 SMTP;但「往messages表插一行」的入口 ≥8 处,各自拼节奏和outreach_queued_at。入站mail/route.ts871 行:剥引用 → 判类型 → 按线程头 / 发件人认领行 → 立刻把行改成replied→ 排negotiate。 - 计费:
feature_prices一功能一价,record_usage幂等(只在调用方传幂等键时生效),metering/context.ts带三层「迁移没跑」降级。扣费点 65 处 / 15 个文件。「没额度」的判定点有 6 处、口径不一。
2. 业务流程 ↔ 代码流程
下表每步给出:触发 → 入口与链路 → 写哪些表/状态 → 写哪些事件 → 界面怎么知道 → 已知问题。路径相对 src/lib/。
| 步 | 触发 | 入口与调用链 | 写表 / 状态 | 事件 | 界面 | 已知问题 |
|---|---|---|---|---|---|---|
| ① 新对话 & 建活动 | 用户在 /c/[threadId] 输入(可用「/」意图菜单) | api/copilot → agent/loop.runTurn → toolbox campaign_create → ai/tool-exec create_campaign(216 行);模型按 playbooks.campaign_setup 问六项 + 品牌名 | campaigns、guardrails(max_offer = 预算 ÷ 人数)、threads.title、agent_runs(match, strategy) | campaign_created | 流式文本 + effect:campaign_created 刷右屏 | 硬闸:品牌名必填、userConfirmedBrief !== true 不建、一对话一活动。CampaignFormDrawer.tsx 845 行只剩总览里「编辑 Brief」在用;agents/brief.ts 全仓无调用(死代码) |
| ② 匹配 | 建活动自动;右屏按钮;Agent creators_find;梯子 ⑥ 补人 | runner.runMatch(977 行):取池(分页全量、排除别活动联系过的)→ hard-conditions(均播/互动率/平台/Shorts/品牌号)→ 品类判据 → agents/match.scoreCandidate(市场、层级为剔除闸,未知的 hard_unverified 只排后)→ 不够就 pool-topup(EasyKOL → HypeAuditor)→ selectStrict 不放宽 → brand-check 模型判品牌号 → 写理由 | campaign_influencers upsert shortlisted + match_score / match_reason / hard_unverified;已推进的行只刷分不动状态 | pool_topup、match_finished(from=agent 或一个新人都没有才 wake) | TalentMatch.tsx;useRunningJobs 每 3s 轮询 agent_runs | 市场闸只看 primary_market,老数据全空;platforms 为空的行直接过平台闸;品牌号只判这一轮要入选的、每轮 ≤150;「点了没反应」是入队即返、按钮几百毫秒就松开 |
| ③ 挑人 & 写外联 | 右屏勾选 confirmShortlist;梯子 ③ curateStep(autopilot 自动、其余出 curate:<id> 卡);Agent outreach_send | runner.runOutreach:人设未设 → run_failed 退出;只取 shortlisted && excluded_at null;取邮箱;有模板走 outreach-template 填空,否则 agents/outreach 写;8 并发 240s,超时剩下的再排一个任务 | status=draft_ready + outreach_copy / subject | outreach_sent(写好 n 封等放行 / 自动排队) | OutreachApproval.tsx 一行一位看板 | 卡只列有邮箱的(无邮箱的只在文案附注);旧卡无 rowIds 时点一下发全部;右屏「全部发送」默认全选 |
| ④ 发信 | 人点卡 / autopilot 自动 | executeConfirmed send_outreach → actions.sendOutreach(原子占位 outreach_queued_at)→ messages(deliver_at) 按 pacing.spreadOutbound 错开 3–9 分钟且落在人设工作时段 → cron deliver → runner.deliverDueMessages:收尸 → 取到点的 30 封 → 暂停活动推后 → 阻塞判定(没邮箱/没发信邮箱/退信/配额)→ 组信 → SMTP → 成功才写 delivered | messages.delivery=sent / sent_at / email_message_id;首封把行改 status=sent | approval_resolved、run_failed(寄不出的合并一条) | 右屏「寄不出」tab | 「queued to send 卡三分钟」= deliver_at 错开 + cron 每分钟 + 每拍只认领 35s 内的;kickDelivery 在 cron 上下文无 origin 时静默;Agent 看到的工具回执是「排进队列」不是寄出 |
| ⑤ 收信 & 谈判 | cron inbox 每分钟 IMAP | mail/route.routeInbound → resolveTarget(线程头限本组织;否则发件人邮箱,只认 outreach_sent_at 非空且非 declined 的行)→ 认不到记 unrouted 只留信头 → 插 messages(sender=creator) → 立刻 status=replied → 附件落文件区、网盘链接排 fetch_link → 排 negotiate。runner.runNegotiate:等 fetch_link → 拼上下文 → tryCloseOrDecline(置信 ≥0.8 → decide(close_deal):deny 升级 / ask 出 deal: 卡 / auto 直接 agreed + 出合同)→ agents/negotiate → 三支:升级(defection_reason + holding line + wake)/ 待批(插 pending_actions)/ 自动寄(scheduleGroup 20 分钟–6 小时) | replied → ai_negotiating / agreed;pending_actions;counter_offer / negotiation_rounds / concessions_used | kol_replied、offer_made、escalated(wake)、deal_closed(不 wake) | NegotiationDesk.tsx 1,562 行,状态从 status + pending_actions + messages.delivery 三处拼 | 「已回复没进谈判」四条路:落 unrouted、no_session、任务被积分/暂停闸压在 queued、被判 superseded / human_took_over;replied 是入站立刻写的,谈判没跑也显示已回复。「approval 模式 AI 自动回了」:判定表里 reply_chat 在 approval 档是 auto,只有带价/让步/跟进/首封才 ask |
| ⑥ 追信 / 放手 / 补人 | cron agent → heartbeatAutopilot → enqueueDueAdvances;任务跑完 chainAdvance | advance.advanceCampaign ⓪–⑦;⑤ nudge_count ≥ 2 放手(approval 档是 auto),否则 48h 追一封(ask 出 nudge: 卡);⑥ 池空补 match;拍末 withdrawStale 收回条件不成立的卡 | nudge_count、declined、再排 match / negotiate | approval_requested(nudge / curate / release)、autopilot_step | 对话里的卡 | 卡状态靠扫事件流;Agent 设的 hold_until / no_followup / keep 只在 agentHeld 和投递的 followUpHold 两处被读;例外交给 Agent 只在 ⑥ 池空一处真的做了,其余仍是硬编码兜底 |
| ⑦ 成交 & 合同 | 谈判自动 / 人点 close_deal 卡 / Agent creators_update op=close_deal | 三条路各自 update campaign_influencers 再调同一个 data/deal.settleNegotiation;files/contract.issueContract 出 docx → files(kind=contract) → messages 带附件 → 投递寄出时写 contracts.sent_at | agreed_fee、status=agreed → signed(唯一带前置守卫 .eq("status","agreed") 的写入点在 files/extract.ts)、contracts | deal_closed、document_sent、contract_signed | 谈判台 | 三条路写事件、openContentSlot 各写各的;曾按未寄出的更高报价成交(counter_offer 是生成那一刻写的) |
| ⑧ 交片 & 审片 | 邮件附件 / fetch_link 拉网盘 / 界面或 Agent content_update op=submit | 共用 content/submit-video.attachVideoToRow → content_pieces(in_review) → runner.runReview:汇总要求(合同 > 谈成条款 > brief > 活动)→ ffmpeg 压审核版 → agents/review 整条给 Gemini → content_reviews | content_pieces、content_reviews、content_frames | content_arrived、review_ready(wake) | 内容 tab | 「说没视频」:Drive 链接需配 key 且公开才自动拉;YouTube / 其他 host 不拉;未到成交阶段的链接不算视频;read_content_review 只认 SUBMITTED 状态,文件区有视频但没 submit 时回「还没有人交内容」 |
| ⑨ 复盘 & 计费 | 成交后自排 / 人点 | runner.runRetro(节省由代码算、复盘由模型写);metering/context.chargeFeature | campaigns.retro_report、usage_events、credit_lots | retro_ready、credits_exhausted | 复盘 tab;对话里「积分用完」一句 | 「一直说没额度」:余额判定 6 处(requireOrg 402、enqueue 入口、claim_next_run 静默不认领、任务中透支 5 分、wake 不排、提示词 USED UP),口径不一;claim_next_run 不认领时界面原来显示「正在做」 |
一拍能串起多少层:cron → runNextJob → claim_next_run(11 条件)→ HANDLERS[kind] → 跑完 chainAdvance 排 advance → 下一拍 advanceCampaign → propose 出卡 / 排 outreach、negotiate、match → 跑完写事件 → requestWake(60s 去抖)→ 下一拍 runTurn。用户看到的「on it」卡住,是这条链里任何一环(积分闸、暂停闸、cron 延迟、kickQueue 无 origin、建活动前没有 campaignId 导致 useRunningJobs(null) 返回空)的统一表现,前端没有「做到哪一步」的机制,只有同一请求内的 liveSteps。
前端怎么知道后台在干嘛:useRunningJobs(campaign.id) 每 3s 轮询只查有 campaign_id 的 queued/running 任务;对话气泡和右屏共用这一份。请求超时 handoff 后,气泡「还在处理」只在 5s 轮询拉到 assistant 消息或 run_failed 事件才消失。卡片由 approval_requested / approval_resolved 事件按 key 重建。
3. 毛线团在哪:七条根因
3.1 没有领域层,没有状态机
lib/data/actions.ts4,106 行、55 个 server action,直接拼 PostgREST 查询;典型形状是supabaseServer()→.from("campaigns").insert→.from("guardrails").insert→revalidatePath。横切全靠手写:supabaseServer()51 次、requireOrg(28 次(即约一半 action 不走它、靠 RLS)、appendThreadEvent(23 次、revalidatePath(49 次、userError(156 次。declineAsk550 行(3299–3849),内部按action字符串分 11 支,实为「确认卡执行器」;closeDeal238、sendOutreach205、counterOffer191 行。- 创作者 13 态(
status.ts:28-41)没有转移表,grep 不到任何 transition / canTransition。campaign_influencers.status在 10 个文件 33 处裸写;setRowStatus(actions.ts:618)让 UI 传什么写什么;awaiting_reply曾是「画得出来、全项目没有一处写入」的状态(advance.ts:2423注释自述)。活动状态campaigns.status只在runner.ts:1226写过一次strategy,界面改为从管道行推导,status.ts:228-237注释承认库列已漂移。 - 两张核心表:
campaigns初始 26 列、14 个迁移 ALTER、最终 45 列;campaign_influencers初始 20 列、15 个迁移、最终 44 列。一行里同时存状态、子状态标记(outreach_queued_at / excluded_at / hold_until / no_followup / keep / defection_reason当「升级中」)和缓存(thread_digest×4)。每个新需求 = 加一列 + 加一个写入点 + 加一段 if。
3.2 新内核叠在旧层上,三层工具体系,双向依赖
- 模型看到的是
agent/toolbox-spec.ts的 29 个工具(文件头注释写「没有活动时 4 个,有了 22 个」已过时);agent/toolbox.ts916 行里约 770 行是新逻辑:14 个纯转发runTool,13 个自写(creators_query、campaign_get5 个 section、negotiation_instruct、creators_update14 个 op 里 9 个自写…);旧ai/tool-exec.ts2,058 行runTool31 个 case 仍是唯一执行层,47 处直接.from()写表,remove_creatorscase 87 行已不可达。 toolbox的 remove(377–409)与tool-exec的remove_creators(1200–1278)同一套 removable / keptDeals / contacted 口径各写一遍,注释自承「同一套口径」;候选过滤口径在agent/creator-filter.ts第四次实现。ai/tools.ts949 行(31 个旧工具定义)在src内唯一引用是toolbox-spec.ts:25借 15 个 schema;tier、description只有scripts/test-*.mjs在读。运行时约九成是尸体,但平价测试还按它查,谁也不敢删(backlog 里记着「实现层工具说明的去留 P3」)。- 依赖方向:
agent → ai静态 import 16 处;ai → agent、data → agent全是await import或类型引用(runner.ts:32唯一静态)。tool-exec ↔ toolbox、runner ↔ job/store、actions ↔ toolbox/triggers三对双向依赖靠动态 import 规避循环。「内核在lib/agent」并不成立:内核仍依赖ai/与data/的大量旧实现。
3.3 横切关注点没有落点
- 权限
decide()分布 12 个文件;tool-exec.ts只有 3 处、advance.ts13 处、negotiate.ts3 处;agent/gate-rules.ts:13注释:「钱的上限在工具里面的 decide()」。同一动作在 toolbox 权限层、tool-exec、runConfirmedAction三层各判一段。 - 事件
appendThreadEvent90 处 / 17 文件,runClaimedJob(runner.ts:3955)注释自承「七个地方要记得写、漏一个不报错」。写事件失败吞掉只打日志(thread-events.ts:66-69)——而事件是状态源(§3.5)。 - 计费 65 处 / 15 文件(
actions.ts25、runner.ts16)。 - 重试策略靠中文错误串子串匹配(
runner.ts:3321-3340NO_RETRY),加一份文案编号NO_RETRY_KEYS,两套并存,注释自承前者认不出英文版。
3.4 同一件事多份实现
ARCHITECTURE.md §13 自己写「已应验五次」,实测更多:
| 同一件事 | 份数 | 在哪 |
|---|---|---|
| 金额提取 | ≥5 | lib/money.ts(主)、agent/directive-guard.ts amountsIn、agent/consent-rules.ts numbersIn、agents/terms-rules.ts numbersIn、style/their-numbers.ts,各自一张 k / 万 乘数表 |
往 messages 表排信 | ≥8 | runner.ts:2315(谈判回信)、advance.ts 2 处、dispatch-pending.ts:92、actions.ts 3 处、decline.ts、files/contract.ts;真正寄出只有 deliverDueMessages |
| 成交推进 | 3 | actions.closeDeal(人点)、actions.acceptOffer(创作者链接自助,链接已停用)、runner.tryCloseOrDecline(Agent 判定)各自 update 状态再调同一个 settleNegotiation |
| 挑人 / 候选过滤 | 3 + 1 | agents/match.ts 打分 + selectStrict、agents/curate.ts 模型挑、pool-topup.ts 补人;过滤口径 agent/creator-filter.ts 第四份 |
| 确认卡 | 4 套 | 驱动器 propose.ts(key outreach:<c> / release:<row> / deal:<row>:<amt>)、旧 tool-exec 的 confirm 效果(被 loop.ts:541 转 postCard,key chat:<action>:<uuid>)、内核 gate 卡(action agent_tool,key agent:<run>:<call>,唯一一套点完会叫醒 Agent)、toolbox 自判卡(复用驱动器 key)。执行路径两条:executeConfirmed 的 action 分支 vs executeAgentTool 白名单 11 个工具 |
| 审批 | 双轨 | pending_actions 表(谈判草稿,8 个文件)与 thread_messages 里 approval_requested / resolved 事件(其余卡);人工动作也分 approve / reject / redraftPendingAction 与 runConfirmedAction / declineAsk 两组 |
| 一次性链接 vs 邮件 | 2 | CHAT_LINKS_ENABLED = false 但 11 个文件仍引用 chat_sessions,谈判锁仍按 chat_session_id 建,收信找不到会话记 no_session |
| 积分提醒「24h 内已提醒过」 | 2 | metering/stall-notice.ts:42-48、data/credit-actions.ts:62-71 |
3.5 队列表和事件表身兼数职
agent_runs既是队列(14 种 kind)、又是锁(4 个部分唯一索引当锁:advance、negotiate 按input->>'chat_session_id'、turn running、turn queued)、又是进度表(progress / total / output)、还是续跑状态容器(input自由 JSON 里存 stalls / handoff_steps / time_waits / began_at,靠run-merge.ts手工合并)。claim_next_run把暂停、沙盒、余额、删除四种业务闸都塞进认领 SQL;自述「八道闸」,改动前要读 5 个迁移头。thread_messages既是给人看的对话,又是事件日志,又是状态源:确认卡开/关(propose.askStatuslimit 1、propose列开着的卡 limit 500、threads.openAsksByThreadlimit 600 自带去重 reducer、actions.settleOutreachCardslimit 300 自己写 Map 回放、runner.ts:2589limit 5)、冷却期、积分提醒,9 处各扫一遍、limit 各异,同一张卡在不同入口可能得到不同「开/关」结论。campaigns.sandbox是threads.sandbox的冗余副本,靠两个触发器同步。
3.6 测试钉的是源码文本,不是行为
scripts/test-*.mjs94 个文件、1,069 个test();88 个文件readFileSync读src/源码,assert.match / doesNotMatch3,009 条——比 test 用例多两倍。典型断言:assert.match(src, /const \[confirmingId, setConfirmingId\] = useState<string \| null>\(null\);/),即测试钉死某个变量名、某行写法。32 个文件完全不 import 任何src模块。- 测试按客户反馈轮次和审查日期命名:
test-feedback3-chains / feedback4 / feedback5 / feedback6 / feedback7-8 / feedback8b / audit-1017 / agent-review-fixes / agent-live-fixes…,而不是按模块。 - 没有
.github/,没有 CI;npm test跑node --test。本地与线上是同一个 Supabase 项目(ARCHITECTURE §2「本地跑过就等于线上跑过」)。 - 后果:任何重命名、抽函数、合并两份实现,都会让几十个测试变红——而这些红和行为是否正确无关。这是「无法继续迭代」最直接的机械原因。好的一面:
scripts/eval/scenarios.mjs37+ 个评测场景「按结果判、不按措辞判」(踩坑 §3),scripts/e2e/run.mjs真实模型 + 沙盒对话跑全流程,这两套是可保留的资产。
3.7 开发模式:按反馈逐条打补丁
- 579 次提交里 58 条标题含「反馈 / feedback」,151 次碰 ≥10 个文件;最大的一次 76 个文件(ADR 0026 重构)。重构方案 §1 自己统计:9-15 以来 258 个提交,45 个改对话 Agent 那四个文件、78 个改
advance.ts / runner.ts;反馈 6.0 的每一条都同时动工具、梯子、提示词三处。 - 每轮审查都是几十条:2026-08-17「假成功」9 处、09-22 体检 40 条(38 条站住)、09-23 全面审查约 90 条、10-06 审查二约 48 条、10-16 重构后四路审查又 16 条踩坑。修法都是在原函数里加分支,没有抽象层吸收:
runMatch977 行、最大嵌套 8 层、38 个 if,内部串了 EasyKOL 拉取、HypeAuditor 补人、品牌号判定、地域核验、硬条件清扫、写理由、叫醒规则。 - 文档与代码脱节:
ARCHITECTURE.md(最后校对 09-22)和AGENT-SYSTEM.md(09-23)描述的X-Copilot-Effects响应头、tools.ts三轮工具循环都已不存在;doc/演示测试流程.md写的还是「6 步表单」路径;AGENTS.md§1.5 标题仍是「当前进度(2026-08-20)」。ADR 用词很诚实(「架构不换,补的是边界」「是一堆 if」),但 ADR 0024 说「架构形状是对的」十天后 ADR 0026 就推翻了它。 - 根本原因在约束本身:
AGENTS.md§1 写明「工期只有 1–2 周」「不做抽象层、不做插件化、不预留扩展点」「演示链路可跑通 > 视觉完成度 > 功能覆盖面 > 工程健壮性」。这套约束下做出的代码跑了三个月、接了八轮真实客户反馈,但约束从来没有被重新审视。
3.8 两条次要的
- 供应商补丁留在客户端里:
client.ts1,076 行里真正的 HTTP 调用约 100 行;Poe 时代的引用剥离(两份,注释自承「换供应商后是无害空操作」)、jsonInstruction代替 response_format、400 "upstream gateway failure" 当 5xx 重试、402 "used up your points"、"对话必须以 user 结尾" 约束(negotiate.ts:275、projection.ts:151)在 2026-10-19 换到 OpenRouter 后都可删但还在。 - service role 面:
supabase/server.ts:50注释说「只在两处该用」,实际 13 个目录 30+ 文件调supabaseAdmin();系统身份下supabaseServer()静默返回 admin 客户端并伪装成会话客户端类型;RLS 对 worker 路径不构成约束,安全靠一个静态扫描脚本test-service-role-guard.mjs。
4. 合伙人反馈 ↔ 代码根因对照
把 Lark 文档里反复出现的条目映射到上面的根因。「已修」指代码里有对应改动,不代表线上验证过。
| 反馈(轮次) | 代码里的根因 | 对应 §3 | 状态 |
|---|---|---|---|
| 匹配名单地区不对、粉丝量不达标、品牌号还在,「说了三四遍」(2.0 / 6.0 / 8.0) | 市场闸只看 primary_market 且老数据全空只降序不剔;platforms 为空直接过闸;品牌号只判这一轮要入选的、每轮 ≤150、模型判定;要求只写进 selection_notes 文字、匹配不读 | 3.1、3.7 | 11-04 ADR 0037 把要求落成列(hard-conditions.ts),其余未解 |
| 建活动「填表」太手动、brief 反复问(1.0 / 3.0 / 6.0) | playbook 只能改倾向;userConfirmedBrief 硬闸要再确认一轮;campaign_update 属 config 效果走分类器判授权、判不出就出卡;旧 845 行表单抽屉仍在 | 1.2、3.2 | 部分 |
| 点 run match 没反应、on it 卡住、没显示正在 matching、等很久不知道预计时间(2.0 / 4.0 / 6.0) | 入队即返;进度全靠 3s 轮询 agent_runs,建活动前无 campaignId 轮询为空;handoff 后要等 cron;wake 60s 去抖;积分闸静默不认领 | 2、3.5 | 未解(backlog「匹配的分步进度 P3」) |
| 五封写好只显示一封、一次 approve 触发全部(4.0 / 5.0) | 卡只列有邮箱的;旧卡无 rowIds 则发全部;右屏「全部发送」默认全选 | 3.4 | 部分(5.0 #9 修了按钮 busy 状态) |
| queued to send 卡三分钟、说已发完右边没发(4.0 / 7.0) | deliver_at 错开 + cron 每分钟 + 每拍只认领 35s 内;kickDelivery 无 origin 时静默;Agent 看到的回执是「排进队列」 | 1.5、2 | 10-21 修了卡片误报,根因未解 |
| 已回复没进 negotiation;approval 模式下 AI 自动回了说看不到视频(5.0 / 8.0) | 回信落 unrouted / no_session 或任务被闸压住;replied 是入站立刻写的;判定表 reply_chat 在 approval 档是 auto | 2、3.1 | 未解 |
| 谈判状态看不出、opened 是什么意思(5.0) | 状态从 status + defection_reason + pending_actions + messages.delivery 三四处拼 | 3.1、3.5 | 未解 |
| 对话无止尽重复、同一问题问五遍、一个对话框问两个问题(6.0) | 提示词压不住,改成收尾层确定性删重复问题(dropRepeatedQuestion);「一次只问一个」与「六项一次列完」两条规则冲突 | 1.2、3.7 | 10-20 评测 42/42 pass³,线上待验 |
| 一直说没额度、充值后还说没额度(6.0) | 余额判定 6 处口径不一;记忆把 Agent 自己说过的「没额度」记成事实 | 1.5 | 10-16 改记忆只记用户的话 |
| 外联信太 AI、要用我们的模板(2.0 / 3.0 / 6.0) | 首封由 writeOutreach 提示词生成;模板信 10-12 起由代码填空(ADR 0025 §5) | — | 已做模板,质量未验 |
| 一次发 50–100 人看板撑不住(7.0) | OutreachApproval / PipelineBoard 全量渲染;投递每分钟 30 封、外联 8 并发 240s 一批、cron 一拍 40 任务 | 2 | 10-21 改一行一位看板,吞吐未解 |
| 排重:合作过的自动去掉(8.0) | runMatch 取池时排除别活动联系过的 | — | 10-21 已做 |
| 审片说「没 video」、网盘链接看不了(5.0 / 8.0) | Drive 需配 key 且公开;YouTube / 其他 host 不拉;未成交阶段的链接不算视频 | 2 | 部分(10-21 网盘 media kit 可读) |
5. 对照 Funyard 的工程原则
Funyard 是我们自己的 Cloudflare Workers + Durable Object 项目,和 Centrol 规模相近(server 约 1.9 万行 TS,不含生成的目录文件)。拿它的 11 条做法逐条对照,不是说 Funyard 完美(它的浏览器端也没复用 protocol 包),而是这些做法恰好各自防住了 Centrol 的某一类混乱。
| # | Funyard 的做法 | Funyard 证据 | Centrol 现状 | 防的是哪类混乱 |
|---|---|---|---|---|
| 1 | 契约单一来源,服务端进出双向校验,副本漂移即失败 | packages/protocol/index.ts;index.ts:686,713;scripts/protocol.mjs:38 | 没有契约层;工具 schema 两份(tools.ts / toolbox-spec.ts);确认卡 4 套 payload | 三份 schema 各自演化 |
| 2 | 分层单向依赖:HTTP 只翻译命令,业务在 Ledger,适配器不认识账本 | index.ts:54-58;core.ts 不 import accounting / index | agent ↔ ai ↔ data 用 await import 绕循环;路由(runner)里写投递、收尸、计费 | 路由里写业务、业务里回调 UI |
| 3 | 供应商 / 能力是冻结的数据表,不是散落的 if | registry.ts:32-91 Object.freeze;descriptor.ts:40-154;加 Twelve Data 只碰 4 个文件 | HypeAuditor / EasyKOL 分工靠 ADR 0021 文字 + runMatch 里的 if;两个数据源「同一个人存两行」(踩坑笔记) | 加一家供应商改十处 |
| 4 | 公开 ≠ 注册:只有带实网验收证据的路线才进目录 | public-acceptance.ts | 「假成功」审查一次 9 处;界面「已发出」邮件一封没寄过 | 看起来能用的能力混进产品 |
| 5 | 账本原子,状态枚举集中,终态不可再改 | accounting.ts:98-108;core.ts:44,105,1027,1033 | 13 态无转移表、33 处裸写;record_usage 重写 6 次;计费函数曾对外开着(0052 收回) | 重复扣费、状态互相矛盾 |
| 6 | 未知结果只能 pending,终结需证据;幂等靠 request_id + 指纹 | core.ts:829,1034,1423;adapter.ts:828 | 投递认领曾两拍重叠寄两遍;谈判任务曾回两遍;agent_runs.input 合并无版本检查(backlog P1) | 重试风暴、盲目重发 |
| 7 | 一条 check 跑全部门禁,CI 三 job 全绿才部署 | package.json:27;verify.yml:102-103 | 无 CI;npm test 跑源码正则;本地直连生产库;「迁移没跑就上线」「迁移先上代码没部署」各一次事故 | 本地能跑与能上线脱节 |
| 8 | 自动测试与实站验收分开记账,没做就写没做 | docs/README.md:73;protocol/README.md:18 | 测试按反馈轮次命名;评测与 e2e 是真的,但 3,009 条源码正则和它们混在同一个 npm test 里 | 合成测试冒充验收 |
| 9 | 文档三层有冲突优先级;records 只追加、带版本号 | docs/README.md:13-18,70 | ADR 质量很高,但 ARCHITECTURE / AGENT-SYSTEM / 演示测试流程 / AGENTS.md §1.5 全部过时且没有优先级规则 | 文档与代码各说各话 |
| 10 | 迁移哈希锁定;回滚只回代码不回数据;存储只做加法 | production-policy.json;AUTO-DEPLOY.md:31-62 | 69 个迁移幂等执行,但「本地跑过就等于线上跑过」;claim_next_run 整函数重写 8 次 | 一次热修把账本带回过去 |
| 11 | 安全在事务内落地:认证代际每请求核验,日志脱敏 | account-auth-epoch.ts;core.ts:1596-1624 | service role 30+ 文件;曾有「单账号时代的工具变成全站后门」「任何人拿公开 key 扣任意组织积分」 | 改密后旧会话继续生效、凭据进日志 |
一句话:Funyard 把「状态」「契约」「门禁」三件事各自收口在一个地方;Centrol 把这三件事分散在每一个函数里,靠作者的记忆和 ⚠️ 注释维持。
6. 还能不能迭代:三条路与建议
6.1 值得保留的资产
| 资产 | 在哪 | 为什么 |
|---|---|---|
| 领域知识:37 份 ADR、46 份踩坑笔记、8 轮客户反馈落地计划、backlog | doc/dev/ | 三个月真实客户 + 真实邮件 + 真实数据源踩出来的坑,每条有现象 / 根因 / 复现条件;重做时是现成的需求与验收清单 |
| 评测与端到端 | scripts/eval/scenarios.mjs、scripts/e2e/run.mjs、scripts/qa/ | 按结果判不按措辞判;真实模型 + 沙盒对话;故障注入剧本 |
| 常驻 Agent 内核的骨架 | agent/store.ts(唯一索引当锁、requeueRun)、loop.ts 的一步一落库与 tool_call_id 去重、job.ts 续跑 | 解决 Serverless 上长任务的正确形状 |
| 权限层的纯函数 | agent/gate-rules.ts、consent-rules.ts、wake-rules.ts、ai/policy.ts decide() | 效果 × 来源 × 污染判定;钱的上限硬逻辑;可裸测 |
| 谈判出口纪律 | agents/negotiate.ts 的四道闸思路、pacing.ts、「只有 SMTP 成功才算寄出」 | 对外说话的唯一出口 + 拟人化节奏 |
| 匹配的几条规则 | hard-conditions.ts、match.ts 的「不符合 vs 核实不了」分区、selectStrict 不放宽 | 直接对应客户最在意的反馈 |
| 文件区设计 | lib/files/(一行一版本、确定性解析优先、读不出返回原因不返回空) | ADR 0010 |
| 数据源调研 | doc/dev/03-research/(HypeAuditor 的三条限制、EasyKOL、Google Drive 链接、Gmail OAuth 审核) | 采购决策依据 |
6.2 三条路
| 路线 | 做法 | 代价 | 适合 |
|---|---|---|---|
| A. 原地整理 | 先把 3,009 条源码正则测试换成行为测试并加 CI;抽状态机(13 态 + 转移表、唯一写入函数);抽「排信」「成交」「确认卡」各一个入口;删 tools.ts、chat_sessions、Poe 补丁 | 每一步都要先改测试再改代码;三个巨型文件要拆;作者在场才可行;估计 4–6 周看不到新功能 | 合伙人决定继续用这套、且原作者继续投入 |
| B. 抽骨架重建 | 保留 §6.1 的资产和数据库里的创作者库 / 邮件记录;新建产品仓,按「契约 → 状态机 → 领域服务 → 工具 → Agent 循环」分层写;旧评测场景作为验收 | 重写 8.8 万行里真正有业务含量的约 2 万行;要先定契约和状态机;旧数据要迁移 | 我们要把它做成自己的产品、且技术团队由我们配 |
| C. 只保留知识 | 把 ADR / 踩坑 / 反馈 / 评测场景收成需求文档,代码只当参考,按我们的产品叙事(docs/strategy/产品叙事_v1.md)从策略采集用例起步 | 丢掉三个月的代码;但保留了三个月的认知 | 产品形态要变(比如插件、CLI、agency 自用优先) |
6.3 建议
- 不要选 A 让它「再修一轮」。 七条根因里六条是结构性的,每轮审查几十条、修完下一轮又几十条,这个曲线已经持续三个月。再来一轮反馈 9.0,大概率还是同一批问题换个地方出现。
- 无论 B 还是 C,先做三件收口的事再写任何功能: 一份工具 / 事件 / 确认卡的契约(像 Funyard 的
packages/protocol);一张创作者状态转移表和唯一的写入函数;一条 CI 门禁(行为测试 + 迁移校验 + 密钥扫描)。这三件事做完,剩下的功能才能「加一个不碰另一个」。 - 把
doc/dev/当需求源,不要当代码注释读。 建议 PM(product-research-agent)把 46 份踩坑笔记和 8 轮反馈收成一份「网红营销 Agent 的硬规矩清单」(例如:只有 SMTP 成功才算寄出;钱不替用户编;不符合硬条件不进名单;秒回等于穿帮),这份清单是我们产品的验收标准,和代码无关。 - 和合伙人沟通时的说法:不是「代码写得差」——注释、ADR、踩坑笔记的质量在同类项目里是高的;是「两周 demo 的约束下做出的代码被当成产品跑了三个月」,约束从没被重新审视。决定下一步前先定:这是继续做 demo,还是开始做产品。
附录 A:关键文件地图
| 文件 | 行数 | 作用 | 评价 |
|---|---|---|---|
lib/ai/runner.ts | 4,670 | 队列消费者 13 种任务 + SMTP 投递 + 收尸 + 重试 | 整条采集管线、谈判链、投递都在一个文件;73 个 ⚠️ |
lib/data/actions.ts | 4,106 | 55 个 server action | 无领域层;declineAsk 550 行 |
lib/i18n/dictionary.ts | 3,571 | 中英文案(另有 vi / id 各 1,714) | 改动最频繁的文件(169 次) |
lib/ai/advance.ts | 3,017 | 驱动器梯子 | advanceCampaign 985 行;两套编号;56 个 ⚠️ |
lib/ai/tool-exec.ts | 2,058 | 旧工具执行层 runTool 31 case | 47 处直接写表;仍是唯一执行层 |
components/campaign/NegotiationDesk.tsx | 1,562 | 谈判台 | 状态从三四处拼 |
lib/data/queries.ts | 1,356 | 读层 | — |
lib/ai/client.ts | 1,076 | LLM 客户端 | 真正 HTTP 约 100 行,其余是供应商补丁 |
lib/ai/tools.ts | 949 | 旧工具定义 | 运行时九成死代码,测试在读 |
lib/ai/agents/match.ts | 925 | 打分与闸 | 「不符合 vs 核实不了」分区是对的 |
lib/agent/toolbox.ts | 916 | 新工具执行 | 一半自写一半转发 |
components/thread/ThreadChat.tsx | 899 | 对话界面 | 卡片从事件重建;进度靠轮询 |
lib/mail/route.ts | 871 | 入站邮件路由 | 认领规则严格,认不到记 unrouted |
components/campaign/CampaignFormDrawer.tsx | 845 | 旧建活动表单 | 只剩编辑模式在用 |
lib/agent/loop.ts | 778 | 常驻 Agent 回合循环 | 骨架对;塞了模型行为矫正 |
lib/ai/agents/negotiate.ts | 742 | 谈判回信 | 四道闸实为十几个分支 |
lib/agent/toolbox-spec.ts | 660 | 模型看到的 29 个工具 | 头注释过时 |
lib/agent/store.ts | 584 | agent_runs / agent_steps 读写 | 锁与合并做得对 |
supabase/migrations/ | 69 文件 4,759 行 | 40 张表、16 RPC | claim_next_run 重写 8 次 |
scripts/test-*.mjs | 94 文件 | 测试 | 3,009 条源码正则 |
附录 B:数字汇总
| 项 | 数 |
|---|---|
| 源码 | 379 个文件、约 8.8 万行(lib/ai 22,762 / components 20,294 / lib/i18n 8,213 / lib/data 7,886 / lib/agent 5,875 / lib/mail 3,712 / lib/files 3,479 / app 2,952) |
| 提交 | 579(2026-08 191 / 09 226 / 10 160 / 11 2);151 次碰 ≥10 文件;58 条标题含反馈 |
| 数据库 | 40 张表、69 个迁移、16 个 RPC、15 个触发器、12 个部分唯一索引(4 个当锁) |
| 两张核心表 | campaigns 45 列(ALTER 14 次)、campaign_influencers 44 列(ALTER 15 次) |
| 状态写入点 | campaign_influencers.status 33 处 / 10 文件;无转移表 |
| 横切关注点 | decide() 12 文件;appendThreadEvent 90 处 / 17 文件;扣费 65 处 / 15 文件;supabaseAdmin() 30+ 文件;agent_runs 插入 27 处 / 10 文件 |
| 事故注释 ⚠️ | runner.ts 73、advance.ts 56、actions.ts 38、tool-exec.ts 21;lib/agent/ 带日期注释 109 处 |
| 测试 | 94 文件、1,069 用例、3,009 条源码正则断言、88 文件读源码、无 CI |
| 文档 | 37 份 ADR、46 份踩坑笔记、18 份计划、14 份调研、backlog 83 行 |
| 审查轮次 | 08-17 假成功 9 处;09-22 体检 40 条;09-23 全面审查约 90 条;10-06 审查二约 48 条;10-16 重构后 16 条踩坑 |
附录 C:调研方法与局限
- 只读调研,未运行代码、未连库、未在线上复现任何反馈。所有「已知问题」是从代码路径推断的可能原因,不是线上定位结论。
- 行数、处数由
wc/grep在 2026-11-05 的 main 分支上统计;「某处是死代码」的判断基于src/内的静态引用,不排除脚本或迁移引用。 - 五路调研由五个独立会话完成,每条结论附文件:行号,主文档作者抽查过测试、提交历史、目录规模等数字;逐条行号未全部二次核对。
- Lark 反馈文档里含第三方 API 账号信息,本文档未收录;原始反馈以 Lark 文档为准。
- 未读
doc/customer_document/下的 docx(功能边界确认书、合同)。
提示:点击任意图片可放大查看,再点一次缩回。