如何避免 AI 助手的工具寫入重複執行:唯一約束保護記錄,不保護操作
A Unique Constraint Protects the Ledger, Not the Action: Making an AI Write Happen Once
避免 AI 助手工具重複寫入的做法,是在動作執行前以冪等鍵認領執行權,並由認領結果決定哪個請求繼續。冪等鍵由用戶、對話、工具及先排序的參數組成;失敗處理按結果是否可知分流,結果不明的外部寫入會保留認領,重複請求則重播原結果。對外部寫入,機制只能保證不重送,無法保證目標系統只接收一次,後者取決於目標系統是否按鍵去重。
An assistant calls a tool that writes. That call can arrive twice: the model retries it, the same confirmation gets decided twice, or the process restarts mid-flight.
A second arrival must not write a second time. The usual answer is an idempotency key with a unique constraint on it, and that is not enough on its own. The constraint is enforced when the row is written, and the row is written after the action. The ledger ends up with one row while the target has been written twice.
A unique constraint protects the ledger, not the action.
The proposal is to take a claim before the action runs, and to let that claim decide who executes. The rest follows from it.
1. Why "check, then act" is not enough
Start with the version that is easiest to write.
Before executing, check whether the key exists. If it does not, execute, and record the key afterwards.
With one request in flight this is fine. Under concurrency it is broken. Both requests read "not there yet", so both execute. When they later record their keys, the unique constraint rejects the second row. The action has already happened.
So the constraint stopped the second recording, not the second write. That is the sentence above, spelled out: a constraint on the ledger cannot reach an action that has already left it.
There is a name for the shape: check, then act. There is a window between the decision and the action, and whatever happens inside that window is invisible to the decision.
2. The fix: claim before acting, and let the claim arbitrate
The fix is small. Replace the check with a claim.
Before executing, insert a row into a claims table. Its unique constraint is the arbitration point:
- the insert succeeds, so this execution is mine and I continue
- the insert fails because the key is taken, so someone already holds this execution and I do not run it
What matters is not the row. It is that the insert happens before the action. The insert is atomic, so who owns the execution is settled before anything runs, with no window in between.
That also explains why ownership cannot be inferred from completion. If the only thing you can observe is "the write finished", then both concurrent requests observe "not yet" before either acts, and you are back to check, then act.
3. What the key is made of
This part deserves its own section, because it decides what counts as the same write.
Ours is four things hashed together:
hash( who : which conversation : which tool : arguments with keys sorted )
Taking them in turn:
- who. The same tool with the same arguments for a different user is a different write.
- which conversation. A repeat inside one conversation is a retry. The same call twice across two conversations is two events.
- which tool. No explanation needed.
-
the arguments, with their keys sorted first. This one is load-bearing. Sorting makes two spellings of the same call compute the same key. Without it,
{a:1,b:2}and{b:2,a:1}produce two keys, the same call is treated as two writes, and the idempotency has a hole in it.
Those four answer is this the same call. They do not answer what will this write touch. Those are different questions, and collapsing them into one field causes a different set of problems, which is a separate subject.
4. After claiming, if the write fails, does the claim go back
This is the hard part, and our answer is not yes or no. It is two branches, and the branch is chosen by what we actually know.
A local write: we know the outcome. A failure is a failure, a row that landed landed. That branch can release the claim so a retry really retries, or keep it and record the failure honestly. Either way, the point is that we know what we know.
An external write: we do not know. The request went out and no answer came back, or the connection dropped. Across those three cases the other side may well have received it. Release the claim here and let a retry run, and the retry is a real second write.
So the external branch deliberately keeps the claim, and says so in the result:
that request may have reached the target system, the outcome is theirs to report, and nothing is retried here.
That sentence is not a phrasing choice. It is the behavior. When we do not know, we do not let the retry through, because a retry converts "unknown" into "possibly twice".
5. What a second arrival returns
The second request should not write again, and it should not be reported as a failure either. It did nothing wrong.
What we do is replay. On a hit, the recorded outcome is returned to this call, marked as reused:
{ id: <the id recorded the first time>, idempotent: true }
The caller gets the same result the first call got. From its side, "I called once and got an answer" is true.
Composite writes need one more thing. When a single call writes several rows, the base key is held by the root member, so a hit has to replay the whole group rather than the root alone. Return only the root and the caller loses the other members, which will then never be declared again.
6. A boundary we cannot cross
Everything above stops us from sending twice.
It does not make the effect once-only end to end. For that, the target system has to honor the key: we send it along in a header, and whether the other side deduplicates on it is the other side's decision.
Put precisely: we can guarantee we do not resend; we cannot guarantee they receive once.
We put that in the result message rather than leaving it for the reader to discover.
7. A check you can run yourself
Four questions. Any one of them without a good answer is a window.
- Is there a gap between deciding and acting? If the code checks and then does, the gap is there.
- What defines "the same write"? If the key omits who or which conversation, then a different user or a different session with identical arguments collapses into one write, which is the other kind of wrong.
- Are the arguments sorted before hashing? If not, two spellings of one call compute two keys.
- After a failure, what does a retry do? If the answer is anything other than "it depends on whether we know the other side received it", it is probably a guess.
Closing
Back to the sentence: a unique constraint protects the ledger, not the action. Remember that and the first check runs itself, because the question becomes whether the decision happens before the action or after it.
What we do: claim before acting and let the claim arbitrate, key on who and which conversation and which tool and sorted arguments, branch the failure policy by what we know, and replay the original outcome when the same call arrives again.
Two things stated plainly. The boundary above, that we do not guarantee the other side receives once. And a limitation on the mechanism itself: it holds only when the arguments fully express the write. If a tool's behavior also depends on something outside its arguments, such as the current time, a random draw, or external state, then the same arguments twice are not the same write, and a key over them says nothing true. A tool like that has to be changed to take those things as arguments first. Until then the key is decoration.
Drafted with an AI assistant. The system, the positions and the mistakes are mine.
來源:DEV Community · dev.to